import: delete the Mac's contacts one at a time
One save request held five, and one refusal sank the batch;
now each goes on its own, and a refusal keeps just that.
The plan writes down a name beside each id it mints,
so a contact that is kept comes back with better hints.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change rlzmyttoyyyokskoswrsunvutoynpqrn
commit 2bd5e5f74f5537621835aa7d15031ad7c71470e3
author Alpha Chen <alpha@kejadlen.dev>
date
parent xwwytlmo
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index d0893cb..c92516e 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -259,10 +259,21 @@ 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. 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.
+`execute`'s is. The deletes go in one run of the script, since each one
+starts it again, but in one `CNSaveRequest` each: the save is all or
+nothing, and the store refuses some contacts it will hand over quite
+happily, so a batch would lose every other delete to that one. The
+script prints per identifier whether the delete took, a refused one is
+kept and printed like any other kept contact, and the statuses are
+recorded after: a run that dies between the two finds those contacts
+gone next time and records them then.
+
+A refusal is `NSCocoaErrorDomain` 134092 out of `store.execute`, with
+`CoreData: error: Unhandled error occurred during faulting` on stderr,
+and no key set avoids it — identifier alone fails the same way the full
+fetch does. What makes a contact refusable is not known; the one that
+turned this up was the only one of its plan carrying both a photo and
+`X-ABRELATEDNAMES`. Deleting it in Contacts.app is the way out.
 
 The comparison is exact because CNContact exposes no modification date.
 A contact Contacts.app touched on its own will be kept, which is the
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index a9dcee1..ffc4df6 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -80,9 +80,15 @@ module ProTacts
           }
         end
 
-        #: (Array[String] source_ids) -> void
+        # The contacts this Mac would not delete, each to what it said;
+        # empty when every one of them left.
+        #: (Array[String] source_ids) -> Hash[String, String]
         def self.delete(source_ids)
-          Macos.run("delete", *source_ids) unless source_ids.empty?
+          return {} if source_ids.empty?
+
+          Macos.run("delete", *source_ids).reject { it.fetch("deleted") }.to_h {
+            [it.fetch("identifier"), it.fetch("error")] #: [String, String]
+          }
         end
       end
 
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index 417eb7c..ec2d295 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -22,11 +22,14 @@ module ProTacts
       Entry = Data.define(:id, :source_id, :card)
 
       # A contact as the plan lists it, with the last step it reached:
-      # nil, then landed, imported, and removed, in that order.
+      # nil, then landed, imported, and removed, in that order. The name is
+      # the card's when the plan was built, written down so a person
+      # reading plan.yml can tell which line is whose without opening a
+      # card; nothing reads it back to decide anything.
       # @rbs skip
-      Contact = Data.define(:id, :source_id, :status)
+      Contact = Data.define(:id, :source_id, :name, :status)
 
-      FORMAT = 1 #: Integer
+      FORMAT = 2 #: Integer
 
       # Store::EVERYONE, spelled out so a plan loads no database.
       EVERYONE = "sync:*" #: String
@@ -52,7 +55,11 @@ module ProTacts
           "created_at" => created_at.utc.iso8601,
           "group" => group,
           "host" => nil,
-          "contacts" => entries.map { {"id" => it.id, "source_id" => it.source_id, "status" => nil} }
+          "contacts" => entries.map {
+            # A card may hold only one of the two names.
+            person = [it.card.first, it.card.last].reject(&:empty?).join(" ")
+            {"id" => it.id, "source_id" => it.source_id, "name" => person, "status" => nil}
+          }
         })
         plan.save
         plan
@@ -102,7 +109,9 @@ module ProTacts
 
       #: () -> Array[Contact]
       def contacts
-        @manifest.fetch("contacts").map { Contact.new(id: it.fetch("id"), source_id: it.fetch("source_id"), status: it.fetch("status")) }
+        @manifest.fetch("contacts").map {
+          Contact.new(id: it.fetch("id"), source_id: it.fetch("source_id"), name: it.fetch("name"), status: it.fetch("status"))
+        }
       end
 
       #: (String id, String status) -> void
diff --git a/lib/pro_tacts/import/remove.rb b/lib/pro_tacts/import/remove.rb
index c7f1e78..f8df7e7 100644
--- a/lib/pro_tacts/import/remove.rb
+++ b/lib/pro_tacts/import/remove.rb
@@ -15,9 +15,9 @@ module ProTacts
       class Failed < StandardError; end
 
       # What a run did: how many contacts it took off the Mac, and the
-      # ones it left there, each named as its card names it and said why.
-      # A name rather than an id, since the person reading is the one who
-      # will go and look at the contact. Signed in
+      # ones it left there, each as its plan id, the name the plan recorded
+      # and why it stayed. Both, since the person reading has a contact to
+      # find in Contacts and a line to find in plan.yml. Signed in
       # sig/pro_tacts/import.rbs, being a Data class.
       # @rbs skip
       Result = Data.define(:removed, :kept)
@@ -43,19 +43,24 @@ module ProTacts
         # what a rerun of an interrupted run sees.
         gone.each { @plan.record(it.id, "removed") }
 
-        kept = [] #: Array[[String, String]]
+        kept = [] #: Array[[String, String, String]]
         going = still_there.select { |contact|
           why = keep(contact, records.fetch(contact.source_id))
-          kept << [name(contact.id), why] if why
+          kept << [contact.id, contact.name, 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") }
+        # One call for the batch, since each one starts the script again,
+        # and one save request per contact inside it, so a contact the
+        # store refuses is the only one that stays. A run that dies before
+        # the plan records the rest finds them gone next time.
+        refused = @mac.delete(going.map(&:source_id))
+        removed, stuck = going.partition { !refused.key?(it.source_id) }
+        removed.each { @plan.record(it.id, "removed") }
+        stuck.each {
+          kept << [it.id, it.name, "this Mac would not delete #{it.source_id}: #{refused.fetch(it.source_id)}"]
+        }
 
-        Result.new(removed: gone.size + going.size, kept:)
+        Result.new(removed: gone.size + removed.size, kept:)
       end
 
       private
@@ -76,14 +81,6 @@ module ProTacts
         end
       end
 
-      # The contact as its card file names it, which is what a person
-      # reading this run looks for in Contacts.
-      #: (String id) -> String
-      def name(id)
-        card = @plan.card(id)
-        [card.first, card.last].reject(&:empty?).join(" ")
-      end
-
       # Asked of the card browser rather than of `/dav/addressbook`,
       # which serves the asking user's book alone: a card in no `sync:`
       # group is on the host and outside every book, and the question
diff --git a/script/macos-contacts.swift b/script/macos-contacts.swift
index af2b3d3..f6ab711 100644
--- a/script/macos-contacts.swift
+++ b/script/macos-contacts.swift
@@ -13,8 +13,10 @@
 // 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.
+// out the ones this Mac no longer has; delete deletes them one at a time
+// and prints, per identifier it still has, whether the delete took and the
+// error if it did not. An identifier this Mac no longer has is already
+// deleted, and prints nothing.
 
 import Contacts
 import Foundation
@@ -152,20 +154,25 @@ do {
 }
 
 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()
+  // One save request per contact, because CNSaveRequest is all or nothing
+  // and the store refuses some contacts it will hand over quite happily:
+  // batched, one of those takes every other delete down with it. An
+  // identifier that matched nothing prints no line, a delete of a contact
+  // this Mac no longer has being a delete that already happened.
   for contact in people {
     guard let mutable = contact.mutableCopy() as? CNMutableContact else { fail("\(contact.identifier) will not copy") }
+    let request = CNSaveRequest()
     request.delete(mutable)
-  }
-  if !people.isEmpty {
+    var result: [String: Any] = ["identifier": contact.identifier]
     do {
       try store.execute(request)
+      result["deleted"] = true
     } catch {
-      fail("deleting \(people.count) contacts: \(error)")
+      result["deleted"] = false
+      result["error"] = "\(error)"
     }
+    let line = try! JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
+    FileHandle.standardOutput.write(line + Data("\n".utf8))
   }
   exit(0)
 }
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index d4cc882..190e3df 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -36,18 +36,19 @@ module ProTacts
       class Contact
         attr_reader id: String
         attr_reader source_id: String
+        attr_reader name: String
         attr_reader status: String?
 
-        def self.new: (id: String, source_id: String, status: String?) -> instance
+        def self.new: (id: String, source_id: String, name: String, status: String?) -> instance
       end
     end
 
     class Remove
       class Result
         attr_reader removed: Integer
-        attr_reader kept: Array[[String, String]]
+        attr_reader kept: Array[[String, String, String]]
 
-        def self.new: (removed: Integer, kept: Array[[String, String]]) -> instance
+        def self.new: (removed: Integer, kept: Array[[String, String, String]]) -> instance
       end
     end
 
@@ -71,7 +72,7 @@ module ProTacts
     interface _Mac
       def show: (Array[String] source_ids) -> Hash[String, untyped]
 
-      def delete: (Array[String] source_ids) -> void
+      def delete: (Array[String] source_ids) -> Hash[String, String]
     end
   end
 end
diff --git a/tasks/import.rake b/tasks/import.rake
index ad7ec92..f094b66 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -40,8 +40,7 @@ namespace :import do
       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}"
+        puts "#{contact.id}  #{contact.name}#{card.phones.map { "  #{it}" }.join}"
       end
       puts "#{plan.contacts.size} contacts planned in #{dir}"
     rescue ProTacts::Import::Macos::Unknown => error
@@ -67,7 +66,7 @@ namespace :import do
           plan, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
         )
       end
-      result.kept.each { |name, why| puts "kept #{name}: #{why}" }
+      result.kept.each { |id, name, why| puts "kept #{id} #{name}: #{why}" }
       puts "#{result.removed} contacts removed from this Mac"
     end
   end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index c813da6..5ba6a5e 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -31,11 +31,21 @@ 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 [Plan::Contact.new(id: "kmnuqmzxylru", source_id: "kmnuqmzxylru:ABPerson", name: "Ada Lovelace", status: nil), Plan::Contact.new(id: "vmnlryyvktux", source_id: "vmnlryyvktux:ABPerson", name: "Ada Lovelace", status: nil)], plan.contacts
       assert_nil plan.host
     end
   end
 
+  def test_a_contact_with_one_name_is_listed_under_it
+    in_tmpdir do |dir|
+      card = ProTacts::Import::Card.new(first: "Mitch", last: "", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
+
+      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", card:)])
+
+      assert_equal ["Mitch"], Plan.read(dir).contacts.map(&:name)
+    end
+  end
+
   def test_the_group_is_named_for_when_the_plan_was_built
     in_tmpdir do |dir|
       Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [])
diff --git a/test/pro_tacts/import/test_remove.rb b/test/pro_tacts/import/test_remove.rb
index 89a898d..5560f0c 100644
--- a/test/pro_tacts/import/test_remove.rb
+++ b/test/pro_tacts/import/test_remove.rb
@@ -21,20 +21,26 @@ class ImportRemoveTest < Minitest::Test
   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.
+  # id, and a delete that takes them out of this hash. A source id in
+  # `refuses` is one the store will not delete, which is a real thing a Mac
+  # does (docs/plans/2026-09-16-importing-from-macos.md, "Removing the
+  # originals").
   class Mac
     attr_reader :deleted
 
-    def initialize(records)
+    def initialize(records, refuses: {})
       @records = records
+      @refuses = refuses
       @deleted = []
     end
 
     def show(source_ids) = @records.slice(*source_ids)
 
     def delete(source_ids)
-      @deleted.concat(source_ids)
-      @records = @records.except(*source_ids)
+      going = source_ids - @refuses.keys
+      @deleted.concat(going)
+      @records = @records.except(*going)
+      @refuses.slice(*source_ids)
     end
   end
 
@@ -87,7 +93,7 @@ class ImportRemoveTest < Minitest::Test
 
       result = Remove.call(plan, client:, mac:)
 
-      assert_equal [["Contact #{IDS.first}", "it has changed on this Mac since the plan"]], result.kept
+      assert_equal [[IDS.first, "Contact #{IDS.first}", "it has changed on this Mac since the plan"]], result.kept
       assert_equal [source_id(IDS.last)], mac.deleted
       assert_equal ["imported", "removed"], Plan.read(plan.dir).contacts.map(&:status)
     end
@@ -101,7 +107,7 @@ class ImportRemoveTest < Minitest::Test
 
       result = Remove.call(plan, client:, mac:)
 
-      assert_equal [["Contact #{IDS.first}", "its note has changed on this Mac since the plan"]], result.kept
+      assert_equal [[IDS.first, "Contact #{IDS.first}", "its note has changed on this Mac since the plan"]], result.kept
       assert_equal [source_id(IDS.last)], mac.deleted
     end
   end
@@ -113,7 +119,7 @@ class ImportRemoveTest < Minitest::Test
 
       result = Remove.call(plan, client:, mac:)
 
-      assert_equal [["Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
+      assert_equal [[IDS.first, "Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
       assert_equal [source_id(IDS.last)], mac.deleted
     end
   end
@@ -144,6 +150,22 @@ class ImportRemoveTest < Minitest::Test
     end
   end
 
+  # The store refuses some contacts it will hand over quite happily, so a
+  # refusal keeps its own contact and no other: the rest still leave, and
+  # the plan records them.
+  def test_a_contact_the_mac_will_not_delete_is_kept_alone
+    with_landed_plan do |plan, _store, client|
+      mac = Mac.new(IDS.to_h { [source_id(it), record(it)] }, refuses: {source_id(IDS.first) => "faulting, 134092"})
+
+      result = Remove.call(plan, client:, mac:)
+
+      assert_equal 1, result.removed
+      assert_equal [[IDS.first, "Contact #{IDS.first}", "this Mac would not delete #{source_id(IDS.first)}: faulting, 134092"]], result.kept
+      assert_equal [source_id(IDS.last)], mac.deleted
+      assert_equal ["imported", "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)] })