import: a saved slot that will not read raises
Staged.saved answered a saved.json that was not an object as nothing
saved, and Staged.ids did the same for its contacts, so a broken file
would send every row back through a Save that writes its contact a
second time. Both raise now, #rows' posture; a missing file is still
the gone import it always was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kqo9sKAi4DnKCQkrtYoAwM
change
commit 5bc1457943e943d3959485ab05e17675f1a18d5c
author Claude <noreply@anthropic.com>
date
parent 029f6537
diff --git a/lib/pro_tacts/import/staged.rb b/lib/pro_tacts/import/staged.rb
index e935551..9831491 100644
--- a/lib/pro_tacts/import/staged.rb
+++ b/lib/pro_tacts/import/staged.rb
@@ -137,20 +137,20 @@ module ProTacts
       # What this import has done: the group it files under, and the
       # contact each saved card became, by the card's place in the
       # file — the name every screen of the walk calls a contact by
-      # until there is a minted id to call it by instead. Two empty
-      # answers for an import that is gone or a slot that will not
-      # parse: this is a file on disk, and one that will not read back
-      # in the shape it was written in is a broken assumption JSON
-      # says so about.
+      # until there is a minted id to call it by instead. Empty for an
+      # import that is gone, #read's ordinary case. A slot that will
+      # not read back in the shape #open wrote it in raises, #rows'
+      # posture: answering "nothing saved" would send every row back
+      # through a Save that writes its contact a second time.
       #: (String id) -> [String, Hash[String, String]]
       def self.saved(id)
         raw = read(id, SAVED)
-        return ["", ids(nil)] if raw.nil?
+        return ["", ids({})] if raw.nil?
 
         parsed = JSON.parse(raw)
-        return ["", ids(nil)] unless parsed.is_a?(Hash)
+        raise ArgumentError, "#{id}'s saved slot is not an object" unless parsed.is_a?(Hash)
 
-        [parsed[GROUP].to_s, ids(parsed[CONTACTS])]
+        [parsed.fetch(GROUP).to_s, ids(parsed.fetch(CONTACTS))]
       end
 
       # One card, written: its place in the file against the id the
@@ -233,12 +233,13 @@ module ProTacts
       end
 
       # One map of a contact's place in the file to the id it was
-      # stored under, out of whatever the file actually holds.
+      # stored under, refused where the slot holds anything else
+      # (#saved's reason).
       #: (untyped raw) -> Hash[String, String]
       def self.ids(raw)
-        ids = {} #: Hash[String, String]
-        return ids unless raw.is_a?(Hash)
+        raise ArgumentError, "the saved slot's contacts are not an object" unless raw.is_a?(Hash)
 
+        ids = {} #: Hash[String, String]
         raw.each { |index, contact| ids[index.to_s] = contact.to_s }
         ids
       end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 6ea71b5..0f09b87 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -638,6 +638,18 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
+  # The walk's record of what it saved, read back in a shape it was
+  # never written in: a broken assumption to raise on, not "nothing
+  # saved", which would send every row through a second Save.
+  def test_a_saved_slot_that_is_not_an_object_raises
+    with_contacts({}) do
+      id = upload(JANE)
+      (Pathname.new(ENV.fetch("PRO_TACTS_DATA_DIR")) / ProTacts::Import::Staged::DIRECTORY / id / "saved.json").binwrite("[]")
+
+      assert_raises(ArgumentError) { get "/import/#{id}/0" }
+    end
+  end
+
   # The group for the import is named when the file is uploaded;
   # which of this book's own groups a contact joins is asked beside
   # that contact's card, and written by the same Save as its fields.