import: keep a plan as YAML a person can edit
The plan is YAML now, and a card is a form: a name, the phones, the groups it goes in,
so the file a person edits before execute is the contact the host takes in.
The builder fills those fields from FN, N, and one exact cell TEL, and every other line is refused:
a field holds less than a line does, so a name it would shorten is news.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue: rtqvmzvp
change lruxyzupwpostzwkqsoqruumxwnzqtxs
commit 25bc6f587e49f7cfdadf43a6c5e758aec7df54c2
author Alpha Chen <alpha@kejadlen.dev>
date
parent omlplqky
diff --git a/AGENTS.md b/AGENTS.md
index 516eb68..02c9090 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,8 +26,8 @@ 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 an import plan on a host
-                      # (PLAN=dir HOST=https://contacts)
+rake import:execute   # Land the newest plan in data/imports 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 3bcf1e5..b25fe31 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -13,15 +13,15 @@ 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; then the host, the group's id, and each contact's status |
-| `cards/<id>.vcf` | The bytes `execute` PUTs, byte for byte |
+| `plan.yml` | The source, when it was built, the group, and each contact's minted id beside its source id; then the host and each contact's status |
+| `cards/<id>.yml` | The contact's fields and the groups it belongs to, for a person to edit before `execute` |
 | `backups/<id>/` | The original, in whatever files the source reads it as |
 
-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 builder writes the cards and backups once, and no code rewrites
+them. A person can: a card file is the plan's one open question, read as
+it stands when `execute` carries it.
 
-`plan.json` also records how far `execute` and `remove` have carried the
+`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
 it over the old one, so a run that dies mid-save leaves the plan as the
@@ -52,12 +52,17 @@ original.
 
 ## Execute is the same for every source
 
-`rake import:execute PLAN=dir HOST=host` does the following, recording
+`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:
 
-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`.
+1. Read every card not yet joined, 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
+   `POST /contacts/<id>/groups`, creating any that are missing through
+   `POST /groups`.
 
 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
@@ -69,10 +74,43 @@ first keeps a rerun from collecting refusals, each of which warns Sentry
 - 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 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.
+- A group is looked up by name in `GET /api/groups`, every group's id
+  and name as JSON, before it is created, so a rerun reuses the group an
+  earlier run made.
 - Joining a group twice is joining once (`Store#add_member`).
+- A PUT that creates a card makes it a member of `sync:*`
+  (`2026-09-15-client-creates-join-everyone.md`). The request that sets
+  a card's groups therefore lists `sync:*` among the groups it was
+  already in, so a card whose file does not name `sync:*` is taken back
+  out of it.
+
+## A card is a form filled in
+
+A card file holds what the web editor would: a first and last name and
+a list of phone numbers, plus the names of the groups it belongs to. The plan
+writes `sync:*` and its own `import-<timestamp>` group into every card,
+ahead of any the builder chose.
+
+```yaml
+first: Ada
+last: Lovelace
+phones:
+- "+12532189075"
+groups:
+- sync:*
+- import-20260916T180412Z
+```
+
+`Import::Card` turns the fields into a contact through the web's own
+code: `CardForm.new_card`, then `CardForm.contact_card` with each phone
+as an add row. A phone's types do not survive, because the form has no
+field for them, so a number lands as a bare `TEL`.
+
+A file that will not read as a card is refused, naming the file: a
+missing or unknown key, a name with neither half, or a value YAML reads
+as something other than text. An unquoted `no` is false and an unquoted
+`+12532189075` is a number, and either would otherwise import as
+something no one typed.
 
 `HOST` is a base URL, such as `https://contacts` or the dev server's
 `http://localhost:9292`.
@@ -124,14 +162,16 @@ with image data in base64, and the AppleScript note.
 
 ## The builder knows only what it was taught
 
-The macOS builder turns each line into a card line through a `case` on
-the property name, whose `else` records the line as unknown. The envelope
-is not carried but written around the lines, so the minted `UID` goes
-inside it; a `VERSION` other than 3.0 is unknown. The note and the image
+The macOS builder decides each line through a `case` on the property
+name, whose `else` records the line as unknown, then fills the card's
+fields from the lines it kept. The envelope is dropped, since the web's
+create writes its own; a `VERSION` other than 3.0 is unknown. An Apple `PRODID` is
+dropped, since Contacts writes its own on every save. The note and the image
 data, which the vCard leaves out, are unknown until a branch takes them
 too. Each branch
-names the parameters it accepts, and a parameter outside that list is
-unknown too. Once every contact in the batch is read, any unknown fails
+names the forms it accepts, a form being the group, name, and parameters
+spelled exactly as the source wrote them, and a line in any other form is
+unknown under that form. Once every contact in the batch is read, any unknown fails
 the build with every unknown name, its count, and a few distinct values
 it held, each beside its source id and cut short when long, and
 no plan is written. A small batch needs only the branches its own
@@ -140,8 +180,15 @@ contacts use.
 The `case` starts empty. A property earns a branch when a build refuses
 it, and the branch is written with that property in view, so nothing is
 imported on the strength of a guess about what it holds. Parameter
-values such as `TYPE=IPHONE` are not checked: the server passes `TYPE`
-through, and Apple wrote them.
+values are part of the form, so `TYPE=IPHONE` is refused until a branch
+names it. A property whose value has a shape can check it too: `TEL`
+takes only a `+` and digits.
+
+A field holds less than a line can, so a line that would lose something
+on the way into one is unknown too. The name comes from the one `N`,
+and is refused when `N` holds more than a family and given name, or when
+the one `FN` is not those two joined with a space, the way the web
+editor writes it.
 
 ## Removing the originals
 
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
new file mode 100644
index 0000000..0006079
--- /dev/null
+++ b/lib/pro_tacts/import/card.rb
@@ -0,0 +1,67 @@
+require "yaml"
+
+require "pro_tacts/admin/card_form"
+require "pro_tacts/contact"
+
+module ProTacts
+  module Import
+    # One contact in a plan, as the file a person edits before execute:
+    # the fields the web editor has, and the groups to join
+    # (docs/plans/2026-09-16-importing-from-macos.md, "A card is a form
+    # filled in"). Signed in sig/pro_tacts/import.rbs, being a Data class.
+    # @rbs skip
+    Card = Data.define(:first, :last, :phones, :groups)
+
+    # Reopened for the reason VCard::Parser::Property is.
+    class Card
+      # A card file that will not read as one.
+      class Invalid < StandardError; end
+
+      KEYS = %w[first last phones groups].freeze #: Array[String]
+
+      # 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), 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}" }
+        invalid.("is not a mapping") unless document.is_a?(Hash)
+
+        missing = KEYS - document.keys
+        invalid.("has no #{missing.join(", ")}") unless missing.empty?
+        unknown = document.keys - KEYS
+        invalid.("has #{unknown.join(", ")}, which no field takes") unless unknown.empty?
+
+        first, last, phones, groups = document.values_at(*KEYS)
+        invalid.("first and last must be text; quote them") unless first.is_a?(String) && last.is_a?(String)
+        invalid.("needs a first or last name") if first.strip.empty? && last.strip.empty?
+        invalid.("phones must be a list of quoted numbers") unless texts?(phones)
+        invalid.("groups must be a list of names") unless texts?(groups) && groups.none? { it.strip.empty? }
+
+        new(first:, last:, phones:, groups:)
+      end
+
+      #: (untyped value) -> bool
+      def self.texts?(value)
+        value.is_a?(Array) && value.all?(String)
+      end
+
+      private_class_method :texts?
+
+      # The contact the web create and then the editor's add rows would
+      # make of these fields.
+      #: (String id) -> Contact
+      def contact(id)
+        created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, last), birthday: nil, inherited: [])
+        stored = Admin::CardForm.contact_card(created, first, last, {"new_phone" => phones})
+        Contact.new(id:, stored:, birthday: nil, inherited: [])
+      end
+
+      #: () -> Hash[String, untyped]
+      def document
+        {"first" => first, "last" => last, "phones" => phones, "groups" => groups}
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/import/execute.rb b/lib/pro_tacts/import/execute.rb
index c35e5db..74ee029 100644
--- a/lib/pro_tacts/import/execute.rb
+++ b/lib/pro_tacts/import/execute.rb
@@ -13,6 +13,7 @@ module ProTacts
       # @rbs @plan: Plan
       # @rbs @host: String
       # @rbs @client: _Client
+      # @rbs @group_ids: Hash[String, String]
 
       class Failed < StandardError; end
 
@@ -31,6 +32,7 @@ module ProTacts
         @plan = plan
         @host = host
         @client = client
+        @group_ids = {}
       end
 
       #: () -> void
@@ -39,13 +41,17 @@ module ProTacts
         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 == "joined" }.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) if it.status.nil? }
-        group_id = @plan.group_id || make_group
-        @plan.contacts.each { join(it.id, group_id) if it.status == "landed" }
+        @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" }
       end
 
       private
@@ -54,12 +60,12 @@ module ProTacts
       # 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)
+      #: (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: @plan.card(id)
+          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}"
@@ -68,37 +74,40 @@ module ProTacts
         @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 join(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
+
+        @plan.record(id, "joined")
+      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
+      #: (String name) -> String
+      def group_id(name)
+        @group_ids[name] ||= listed_group_id(name) || make_group(name)
+      end
 
-        @plan.group_id = id
-        id
+      #: (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?
-      def listed_group_id
+      #: (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") == @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")
+        groups.find { it.fetch("name") == name }&.fetch("id")
       end
 
       #: (String path, Array[[String, String]] form) -> Response
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index 701f6cc..565ac60 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -3,6 +3,7 @@ require "open3"
 require "pathname"
 
 require "pro_tacts/change_id"
+require "pro_tacts/import/card"
 require "pro_tacts/import/plan"
 require "pro_tacts/vcard/parser"
 
@@ -89,7 +90,9 @@ module ProTacts
         note = record.fetch("note") #: String?
         contact = record.fetch("contact") #: Hash[String, untyped]
 
-        body = VCard::Parser.lines(vcard).filter_map { line(it, source_id, unknown) }
+        carried = VCard::Parser.lines(vcard).select { carried?(it, source_id, unknown) }
+        first, last = name(carried, source_id, unknown)
+        phones = carried.filter_map { it.property&.then { |property| property.text if property.name.casecmp?("TEL") } }
         # Beyond the vCard: what the serializer leaves out.
         unknown.add("note", source_id, note) if note
         image = contact.fetch("imageData") #: String?
@@ -98,38 +101,95 @@ module ProTacts
         backup = {"original.vcf" => vcard, "contact.json" => JSON.pretty_generate(contact)}
         backup["note.txt"] = note if note
 
-        Plan::Entry.new(
-          id:, source_id:,
-          card: "BEGIN:VCARD\r\nVERSION:3.0\r\n#{body.join}UID:#{id}\r\nEND:VCARD\r\n",
-          backup:
-        )
+        Plan::Entry.new(id:, source_id:, card: Card.new(first:, last:, phones:, groups: []), backup:)
       end
 
-      # A line of the source card as a line of the imported one, or nil for
-      # one it does not carry. The envelope is written around the lines
-      # rather than carried, so the card's UID can go inside it.
-      #: (VCard::Parser::Line line, String source_id, Unknown unknown) -> String?
-      def self.line(line, source_id, unknown)
+      # Whether a line of the source card is carried into the card's
+      # fields; one that is not is recorded unknown or, for the envelope
+      # and PRODID, dropped.
+      #: (VCard::Parser::Line line, String source_id, Unknown unknown) -> bool
+      def self.carried?(line, source_id, unknown)
         property = line.property
-        example = VCard::Parser.unfold(line.verbatim).chomp
+        example = example(line)
         if property.nil?
           unknown.add("unreadable line", source_id, example) unless example.strip.empty?
-          return
+          return false
         end
 
-        case property.name.upcase
+        name = property.name.upcase
+        case name
         when "BEGIN", "END"
-          nil
+          false
         when "VERSION"
           unknown.add("VERSION:#{property.value}", source_id, example) unless property.value == "3.0"
-          nil
+          false
+        when "PRODID"
+          # Contacts writes its own on every save; the source's stays in the backup.
+          form = form(property, example)
+          unknown.add(form, source_id, example) unless form == "PRODID"
+          unknown.add("PRODID value", source_id, example) unless property.value.start_with?("-//Apple Inc.//")
+          false
+        when "FN", "N", "TEL"
+          refused = [] #: Array[String]
+          form = form(property, example)
+          refused << form unless FORMS.fetch(name).include?(form)
+          refused << "TEL value" if name == "TEL" && !property.value.match?(PHONE)
+          refused.each { unknown.add(it, source_id, example) }
+          refused.empty?
         else
-          unknown.add(property.name.upcase, source_id, example)
-          nil
+          unknown.add(name, source_id, example)
+          false
         end
       end
 
-      private_class_method :entry, :line
+      # The first and last name, from the one N, when the one FN is those
+      # two joined as the web editor joins them: a card holding more of a
+      # name than that would lose it.
+      #: (Array[VCard::Parser::Line] lines, String source_id, Unknown unknown) -> [String, String]
+      def self.name(lines, source_id, unknown)
+        ns = lines.select { it.names?("N") }
+        fns = lines.select { it.names?("FN") }
+        unknown.add("N lines: #{ns.size}", source_id, ns.map { example(it) }.join(" / ")) unless ns.size == 1
+        unknown.add("FN lines: #{fns.size}", source_id, fns.map { example(it) }.join(" / ")) unless fns.size == 1
+
+        n = ns.first
+        family, given, *rest = n&.property&.components || []
+        first = given.to_s
+        last = family.to_s
+        unknown.add("N beyond first and last", source_id, example(n)) if n && rest.any? { !it.empty? }
+        unknown.add("no name", source_id, n ? example(n) : "") if n && first.empty? && last.empty?
+
+        fn = fns.first
+        if fn && fn.property&.text != [first, last].reject(&:empty?).join(" ")
+          unknown.add("FN other than first and last", source_id, example(fn))
+        end
+
+        [first, last]
+      end
+
+      # The spellings each carried property is taken in, group and
+      # parameters included, exactly as the source wrote them.
+      FORMS = {
+        "FN" => ["FN"],
+        "N" => ["N"],
+        "TEL" => ["TEL;type=CELL;type=VOICE;type=pref"]
+      }.freeze #: Hash[String, Array[String]]
+
+      PHONE = /\A\+[0-9]+\z/ #: Regexp
+
+      # A line unfolded, without its terminator.
+      #: (VCard::Parser::Line line) -> String
+      def self.example(line)
+        VCard::Parser.unfold(line.verbatim).chomp
+      end
+
+      # A line as the source spelled it, less its value.
+      #: (VCard::Parser::Property property, String example) -> String
+      def self.form(property, example)
+        example.delete_suffix(":#{property.value}")
+      end
+
+      private_class_method :entry, :carried?, :name, :example, :form
     end
   end
 end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index 2477c26..d4b5fb4 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -1,7 +1,9 @@
 require "fileutils"
-require "json"
 require "pathname"
 require "time"
+require "yaml"
+
+require "pro_tacts/import/card"
 
 module ProTacts
   module Import
@@ -12,9 +14,9 @@ module ProTacts
       # @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.
+      # What a builder hands over for one contact: its card, which the
+      # plan's own groups are added to, 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)
 
@@ -25,6 +27,9 @@ module ProTacts
 
       FORMAT = 1 #: Integer
 
+      # Store::EVERYONE, spelled out so a plan loads no database.
+      EVERYONE = "sync:*" #: String
+
       #: (Pathname dir, source: String, created_at: Time, entries: Array[Entry]) -> Plan
       def self.write(dir, source:, created_at:, entries:)
         FileUtils.mkdir_p(dir.dirname)
@@ -32,9 +37,12 @@ module ProTacts
         # another.
         Dir.mkdir(dir)
 
+        group = "import-#{created_at.utc.strftime("%Y%m%dT%H%M%SZ")}"
         entries.each do |entry|
           (dir / "cards").mkpath
-          (dir / "cards/#{entry.id}.vcf").binwrite(entry.card)
+          card = entry.card
+          card = Card.new(first: card.first, last: card.last, phones: card.phones, groups: [EVERYONE, group, *card.groups])
+          (dir / "cards/#{entry.id}.yml").write(YAML.dump(card.document))
           backup = dir / "backups" / entry.id
           backup.mkpath
           entry.backup.each { |name, bytes| (backup / name).binwrite(bytes) }
@@ -44,9 +52,8 @@ module ProTacts
           "format" => FORMAT,
           "source" => source,
           "created_at" => created_at.utc.iso8601,
-          "group" => "import-#{created_at.utc.strftime("%Y%m%dT%H%M%SZ")}",
+          "group" => group,
           "host" => nil,
-          "group_id" => nil,
           "contacts" => entries.map { {"id" => it.id, "source_id" => it.source_id, "status" => nil} }
         })
         plan.save
@@ -55,7 +62,7 @@ module ProTacts
 
       #: (Pathname dir) -> Plan
       def self.read(dir)
-        new(dir, JSON.parse((dir / "plan.json").read))
+        new(dir, YAML.safe_load_file(dir / "plan.yml"))
       end
 
       attr_reader :dir #: Pathname
@@ -88,15 +95,6 @@ module ProTacts
         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")) }
@@ -108,21 +106,22 @@ module ProTacts
         save
       end
 
-      #: (String id) -> String
+      # The card as its file now reads, edits included.
+      #: (String id) -> Card
       def card(id)
-        (dir / "cards/#{id}.vcf").binread
+        Card.read(dir / "cards/#{id}.yml")
       end
 
       # Every step is saved as it is taken, and replaced whole by a rename
       # so a run that dies mid-write leaves the plan as it was before.
       #: () -> void
       def save
-        written = dir / "plan.json.tmp"
+        written = dir / "plan.yml.tmp"
         written.open("w") do |file|
-          file.write(JSON.pretty_generate(@manifest))
+          file.write(YAML.dump(@manifest))
           file.fsync
         end
-        written.rename(dir / "plan.json")
+        written.rename(dir / "plan.yml")
       end
     end
   end
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index fcf589d..0df6dce 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -1,18 +1,27 @@
-# Import::Plan::Entry, ::Contact, and Import::Execute::Response are Data
+# 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.
 module ProTacts
   module Import
+    class Card
+      attr_reader first: String
+      attr_reader last: String
+      attr_reader phones: Array[String]
+      attr_reader groups: Array[String]
+
+      def self.new: (first: String, last: String, phones: Array[String], groups: Array[String]) -> instance
+    end
+
     class Plan
       class Entry
         attr_reader id: String
         attr_reader source_id: String
-        attr_reader card: String
+        attr_reader card: Card
         attr_reader backup: Hash[String, String]
 
-        def self.new: (id: String, source_id: String, card: String, backup: Hash[String, String]) -> instance
+        def self.new: (id: String, source_id: String, card: Card, backup: Hash[String, String]) -> instance
       end
 
       class Contact
diff --git a/tasks/import.rake b/tasks/import.rake
index 1e6d58d..d4b45ee 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -14,13 +14,18 @@ namespace :import do
 
       records = ProTacts::Import::Macos.read(limit:)
       plan = ProTacts::Import::Macos.plan(dir, records, created_at:)
+      plan.contacts.each do |contact|
+        card = plan.card(contact.id)
+        name = [card.first, card.last].reject(&:empty?).join(" ")
+        puts "#{contact.id}  #{name}#{card.phones.map { "  #{it}" }.join}"
+      end
       puts "#{plan.contacts.size} contacts planned in #{dir}"
     rescue ProTacts::Import::Macos::Unknown => error
       abort error.message
     end
   end
 
-  desc "Land the plan in PLAN on the pro-tacts at HOST, a base URL such as https://contacts"
+  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"
   task :execute do
     require "net/http"
     require "pathname"
@@ -29,13 +34,30 @@ namespace :import do
     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)
+    # 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)
+    # 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}"
 
     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}"
+    groups = plan.contacts.flat_map { plan.card(it.id).groups }.uniq
+    puts "#{plan.contacts.size} contacts in #{groups.join(", ")} on #{host}"
   end
 end
diff --git a/test/pro_tacts/import/test_card.rb b/test/pro_tacts/import/test_card.rb
new file mode 100644
index 0000000..0a201f7
--- /dev/null
+++ b/test/pro_tacts/import/test_card.rb
@@ -0,0 +1,84 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "tmpdir"
+
+require "pro_tacts/import/card"
+
+class ImportCardTest < Minitest::Test
+  Card = ProTacts::Import::Card
+
+  ADA = <<~YAML
+    first: Ada
+    last: Lovelace
+    phones:
+    - "+12532189075"
+    groups:
+    - sync:*
+    - import-20260916T180412Z
+  YAML
+
+  def read(yaml)
+    Dir.mktmpdir do |tmp|
+      path = Pathname.new(tmp) / "kmnuqmzxylru.yml"
+      path.write(yaml)
+      Card.read(path)
+    end
+  end
+
+  def assert_invalid(yaml, message)
+    error = assert_raises(Card::Invalid) { read(yaml) }
+    assert_includes error.message, message
+  end
+
+  def test_a_card_reads_its_fields
+    assert_equal Card.new(first: "Ada", last: "Lovelace", phones: ["+12532189075"], groups: ["sync:*", "import-20260916T180412Z"]), read(ADA)
+  end
+
+  def test_a_card_is_the_contact_the_web_create_and_add_rows_make
+    contact = read(ADA).contact("kmnuqmzxylru")
+
+    assert_equal "kmnuqmzxylru", contact.id
+    assert_equal "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Lovelace;Ada;;;\r\nFN:Ada Lovelace\r\nUID:kmnuqmzxylru\r\nTEL:+12532189075\r\nEND:VCARD\r\n", contact.vcard.to_s
+  end
+
+  def test_a_name_is_escaped_into_the_card
+    card = Card.new(first: "Ada; Countess", last: "", phones: [], groups: [])
+
+    assert_equal ["Ada; Countess"], [card.contact("kmnuqmzxylru").name]
+  end
+
+  def test_the_document_reads_back_as_the_card
+    card = read(ADA)
+
+    assert_equal card, read(YAML.dump(card.document))
+  end
+
+  def test_a_missing_field_is_refused
+    assert_invalid ADA.sub("phones:\n- \"+12532189075\"\n", ""), "has no phones"
+  end
+
+  def test_a_field_no_card_takes_is_refused
+    assert_invalid "#{ADA}emails: []\n", "has emails, which no field takes"
+  end
+
+  def test_an_unquoted_number_is_refused
+    assert_invalid ADA.sub('"+12532189075"', "+12532189075"), "phones must be a list of quoted numbers"
+  end
+
+  def test_a_name_yaml_reads_as_something_else_is_refused
+    assert_invalid ADA.sub("last: Lovelace", "last: no"), "first and last must be text"
+  end
+
+  def test_a_card_with_no_name_is_refused
+    assert_invalid ADA.sub("first: Ada", "first: ''").sub("last: Lovelace", "last: ' '"), "needs a first or last name"
+  end
+
+  def test_a_blank_group_is_refused
+    assert_invalid ADA.sub("- sync:*", "- ''"), "groups must be a list of names"
+  end
+
+  def test_a_file_that_is_not_a_mapping_is_refused
+    assert_invalid "- Ada\n", "is not a mapping"
+  end
+end
diff --git a/test/pro_tacts/import/test_execute.rb b/test/pro_tacts/import/test_execute.rb
index 3fe135a..8ca99d3 100644
--- a/test/pro_tacts/import/test_execute.rb
+++ b/test/pro_tacts/import/test_execute.rb
@@ -37,25 +37,49 @@ class ImportExecuteTest < Minitest::Test
     end
   end
 
-  def with_plan(cards = IDS.to_h { [it, card(it, "Contact #{it}")] })
+  # 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 = cards.map { |id, card| Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:, backup: {}) }
+      entries = IDS.map { |id|
+        card = ProTacts::Import::Card.new(first: "Contact", last: id, phones: [], groups:)
+        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" }
+  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 store.contact(it), it }
+      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)
     end
   end
@@ -77,7 +101,7 @@ class ImportExecuteTest < Minitest::Test
   # 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))
+      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:)
 
@@ -106,12 +130,45 @@ class ImportExecuteTest < Minitest::Test
   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:) }
+    with_plan do |plan, _store, _client|
+      error = assert_raises(Execute::Failed) { Execute.call(plan, host: HOST, client: RefusingClient.new) }
 
-      assert_includes error.message, "kmnuqmzxylru"
+      assert_includes error.message, IDS.first
       assert_includes error.message, "412"
-      assert_equal [nil], Plan.read(plan.dir).contacts.map(&:status)
+      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_macos.rb b/test/pro_tacts/import/test_macos.rb
index a7720d1..31a441f 100644
--- a/test/pro_tacts/import/test_macos.rb
+++ b/test/pro_tacts/import/test_macos.rb
@@ -10,12 +10,16 @@ require "pro_tacts/import/plan"
 class ImportMacosTest < Minitest::Test
   Macos = ProTacts::Import::Macos
 
+  Card = ProTacts::Import::Card
+
   CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
+  NAME = ["N:Lovelace;Ada;;;", "FN:Ada Lovelace"].freeze
+  GROUPS = ["sync:*", "import-20260916T180412Z"].freeze
 
-  def record(identifier, lines: [], note: nil, image: nil)
+  def record(identifier, name: NAME, lines: [], note: nil, image: nil)
     {
       "identifier" => identifier,
-      "vcard" => ["BEGIN:VCARD", "VERSION:3.0", *lines, "END:VCARD", ""].join("\r\n"),
+      "vcard" => ["BEGIN:VCARD", "VERSION:3.0", *name, *lines, "END:VCARD", ""].join("\r\n"),
       "contact" => {"identifier" => identifier, "imageData" => image},
       "note" => note
     }
@@ -25,7 +29,7 @@ class ImportMacosTest < Minitest::Test
     Dir.mktmpdir { yield Pathname.new(it) / "plan" }
   end
 
-  def test_an_empty_card_is_planned_under_a_minted_uid
+  def test_a_named_card_is_planned_under_a_minted_id
     in_tmpdir do |dir|
       plan = Macos.plan(dir, [record("A:ABPerson")], created_at: CREATED_AT)
 
@@ -33,7 +37,7 @@ class ImportMacosTest < Minitest::Test
       contact = plan.contacts.first
       assert_match(/\A[k-z]{12}\z/, contact.id)
       assert_equal "A:ABPerson", contact.source_id
-      assert_equal "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:#{contact.id}\r\nEND:VCARD\r\n", plan.card(contact.id)
+      assert_equal Card.new(first: "Ada", last: "Lovelace", phones: [], groups: GROUPS), plan.card(contact.id)
     end
   end
 
@@ -52,19 +56,19 @@ class ImportMacosTest < Minitest::Test
   def test_every_unknown_field_is_named_and_no_plan_is_written
     in_tmpdir do |dir|
       records = [
-        record("A:ABPerson", lines: ["N:Lovelace;Ada;;;", "FN:Ada Lovelace"], note: "Analyst."),
-        record("B:ABPerson", lines: ["FN:Mary Boole"], image: "/9j/")
+        record("A:ABPerson", lines: ["ORG:Analytical Engines;", "EMAIL:ada@example.com"], note: "Analyst."),
+        record("B:ABPerson", lines: ["EMAIL:mary@example.com"], image: "/9j/")
       ]
 
       error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
 
       assert_equal <<~MESSAGE.chomp, error.message
         no branch handles these fields, so no plan was written:
-          FN: 2
-            A:ABPerson: "FN:Ada Lovelace"
-            B:ABPerson: "FN:Mary Boole"
-          N: 1
-            A:ABPerson: "N:Lovelace;Ada;;;"
+          EMAIL: 2
+            A:ABPerson: "EMAIL:ada@example.com"
+            B:ABPerson: "EMAIL:mary@example.com"
+          ORG: 1
+            A:ABPerson: "ORG:Analytical Engines;"
           imageData: 1
             B:ABPerson: "/9j/"
           note: 1
@@ -94,6 +98,106 @@ class ImportMacosTest < Minitest::Test
     end
   end
 
+  def test_a_cell_number_is_a_phone
+    in_tmpdir do |dir|
+      lines = ["TEL;type=CELL;type=VOICE;type=pref:+12532189075", "TEL;type=CELL;type=VOICE;type=pref:+12532189076"]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+      assert_equal ["+12532189075", "+12532189076"], plan.card(plan.contacts.first.id).phones
+    end
+  end
+
+  def test_a_name_is_read_unescaped
+    in_tmpdir do |dir|
+      plan = Macos.plan(dir, [record("A:ABPerson", name: ["N:O\\,Brien;Ada;;;", "FN:Ada O\\,Brien"])], created_at: CREATED_AT)
+
+      assert_equal "O,Brien", plan.card(plan.contacts.first.id).last
+    end
+  end
+
+  def test_a_name_beyond_first_and_last_is_refused
+    in_tmpdir do |dir|
+      records = [
+        record("A:ABPerson", name: ["N:Lovelace;Ada;Augusta;;", "FN:Ada Lovelace"]),
+        record("B:ABPerson", name: ["N:Lovelace;Ada;;;", "FN:Countess Ada Lovelace"]),
+        record("C:ABPerson", name: ["FN:Ada Lovelace"]),
+        record("D:ABPerson", name: ["N:;;;;", "FN:"])
+      ]
+
+      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
+
+      assert_equal <<~MESSAGE.chomp, error.message
+        no branch handles these fields, so no plan was written:
+          FN other than first and last: 2
+            B:ABPerson: "FN:Countess Ada Lovelace"
+            C:ABPerson: "FN:Ada Lovelace"
+          N beyond first and last: 1
+            A:ABPerson: "N:Lovelace;Ada;Augusta;;"
+          N lines: 0: 1
+            C:ABPerson: ""
+          no name: 1
+            D:ABPerson: "N:;;;;"
+      MESSAGE
+    end
+  end
+
+  def test_an_apple_prodid_is_dropped
+    in_tmpdir do |dir|
+      plan = Macos.plan(dir, [record("A:ABPerson", lines: ["PRODID:-//Apple Inc.//macOS 26.0//EN"])], created_at: CREATED_AT)
+
+      assert_equal Card.new(first: "Ada", last: "Lovelace", phones: [], groups: GROUPS), plan.card(plan.contacts.first.id)
+    end
+  end
+
+  def test_any_other_prodid_is_refused
+    in_tmpdir do |dir|
+      records = [
+        record("A:ABPerson", lines: ["PRODID:-//Monica//EN"]),
+        record("B:ABPerson", lines: ["PRODID;VALUE=text:-//Apple Inc.//macOS 26.0//EN"])
+      ]
+
+      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
+
+      assert_equal <<~MESSAGE.chomp, error.message
+        no branch handles these fields, so no plan was written:
+          PRODID value: 1
+            A:ABPerson: "PRODID:-//Monica//EN"
+          PRODID;VALUE=text: 1
+            B:ABPerson: "PRODID;VALUE=text:-//Apple Inc.//macOS 26.0//EN"
+      MESSAGE
+    end
+  end
+
+  def test_a_line_in_any_other_form_is_refused_under_that_form
+    in_tmpdir do |dir|
+      records = [
+        record("A:ABPerson", lines: ["TEL;type=HOME;type=VOICE:+12532189075"]),
+        record("B:ABPerson", lines: ["TEL;type=CELL;type=VOICE:+12532189075"]),
+        record("C:ABPerson", lines: ["item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"]),
+        record("D:ABPerson", lines: ["TEL;type=CELL;type=VOICE;type=pref:(253) 218-9075"]),
+        record("E:ABPerson", name: ["N:Lovelace;Ada;;;", "FN;CHARSET=utf-8:Ada Lovelace"])
+      ]
+
+      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
+
+      assert_equal <<~MESSAGE.chomp, error.message
+        no branch handles these fields, so no plan was written:
+          FN lines: 0: 1
+            E:ABPerson: ""
+          FN;CHARSET=utf-8: 1
+            E:ABPerson: "FN;CHARSET=utf-8:Ada Lovelace"
+          TEL value: 1
+            D:ABPerson: "TEL;type=CELL;type=VOICE;type=pref:(253) 218-9075"
+          TEL;type=CELL;type=VOICE: 1
+            B:ABPerson: "TEL;type=CELL;type=VOICE:+12532189075"
+          TEL;type=HOME;type=VOICE: 1
+            A:ABPerson: "TEL;type=HOME;type=VOICE:+12532189075"
+          item1.TEL;type=CELL;type=VOICE;type=pref: 1
+            C:ABPerson: "item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"
+      MESSAGE
+    end
+  end
+
   def test_a_card_that_is_not_version_3_is_refused
     in_tmpdir do |dir|
       source = record("A:ABPerson")
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index fe493f3..7a68ed4 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -1,6 +1,5 @@
 require_relative "../../test_helper"
 
-require "json"
 require "pathname"
 require "tmpdir"
 
@@ -13,8 +12,10 @@ class ImportPlanTest < Minitest::Test
 
   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:)
+  ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", phones: ["+12532189075"], groups: [])
+
+  def entry(id, source_id: "#{id}:ABPerson", card: ADA, backup: {"original.vcf" => "BEGIN:VCARD\r\n"})
+    Plan::Entry.new(id:, source_id:, card:, backup:)
   end
 
   def in_tmpdir
@@ -30,9 +31,7 @@ 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", 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
 
@@ -44,6 +43,35 @@ class ImportPlanTest < Minitest::Test
     end
   end
 
+  def test_a_card_joins_everyone_and_the_plans_group_after_its_own
+    in_tmpdir do |dir|
+      card = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", phones: ["+12532189075"], groups: ["family"])
+      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", card:)])
+
+      assert_equal <<~YAML, (dir / "cards/kmnuqmzxylru.yml").read
+        ---
+        first: Ada
+        last: Lovelace
+        phones:
+        - "+12532189075"
+        groups:
+        - sync:*
+        - import-20260916T180412Z
+        - family
+      YAML
+    end
+  end
+
+  def test_a_card_reads_back_with_its_edits
+    in_tmpdir do |dir|
+      plan = Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
+      path = dir / "cards/kmnuqmzxylru.yml"
+      path.write(path.read.sub("first: Ada", "first: Augusta Ada"))
+
+      assert_equal "Augusta Ada", plan.card("kmnuqmzxylru").first
+    end
+  end
+
   def test_the_backup_is_written_as_given
     in_tmpdir do |dir|
       photo = "\xFF\xD8\xFF".b
@@ -69,13 +97,11 @@ class ImportPlanTest < Minitest::Test
 
       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/)
+      assert_equal ["plan.yml"], dir.children.map { it.basename.to_s }.grep(/plan/)
     end
   end
 end