profile: sweep only the profiles pointing at this host
The prefix was all that the sweep had to match,
so clearing the dev one took prod in the batch.
A digest of the host is a segment of its own:
a sweep takes its host's and leaves the rest alone.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change ukolsluqkyvlxmupruzzuwmytumqsylu
commit c2f4e94258d1fbbc94ba7b2fafd079421498cea5
author Alpha Chen <alpha@kejadlen.dev>
date
parent lxquxnzo
diff --git a/Rakefile b/Rakefile
index aa19bed..31ba46d 100644
--- a/Rakefile
+++ b/Rakefile
@@ -103,18 +103,30 @@ namespace :profile do
     sh "open", "x-apple.systempreferences:com.apple.preferences.configurationprofiles"
   end
 
-  desc "Remove every installed pro-tacts configuration profile"
+  desc "Remove the installed configuration profiles pointing at PRO_TACTS_HOSTNAME"
   task :remove do
     require "pro_tacts/profile"
 
-    identifiers = ProTacts::Profile.installed_identifiers(`profiles list`)
+    list = `profiles list`
+    hostname = ENV.fetch("PRO_TACTS_HOSTNAME")
+    identifiers = ProTacts::Profile.installed_identifiers(list, hostname:)
     if identifiers.empty?
-      puts "No pro-tacts profiles found; remove by hand in System Settings → Profiles if one lingers."
+      puts "No #{hostname} profiles found; remove by hand in System Settings → Profiles if one lingers."
     else
       identifiers.each do
         sh "profiles", "remove", "-identifier", it
       end
     end
+
+    # A profile pointing at another host provisions another server's
+    # account — the deployment's, beside a dev one — and removing it would
+    # take that account down with it. A profile from before the host was
+    # part of the identifier says nothing about where it points, so it is
+    # reported rather than swept.
+    others = ProTacts::Profile.installed_identifiers(list, hostname: nil) - identifiers
+    unless others.empty?
+      puts "Left installed, from another host or an older identifier (remove by hand if stale):", *others
+    end
   end
 end
 
diff --git a/docs/apple-contacts.md b/docs/apple-contacts.md
index f46f797..a7b20e4 100644
--- a/docs/apple-contacts.md
+++ b/docs/apple-contacts.md
@@ -28,8 +28,8 @@ refusing a redirect) that never reach the server at all.
 ## The account setup path
 
 The fastest path is a configuration profile: `rake profile:install` (with
-`PRO_TACTS_HOSTNAME` set) first removes any installed pro-tacts profiles,
-then renders `carddav.mobileconfig`, opens it, and
+`PRO_TACTS_HOSTNAME` set) first removes the installed profiles pointing at
+that host, then renders `carddav.mobileconfig`, opens it, and
 opens System Settings on the Profiles pane (via the
 `x-apple.systempreferences:` deep link) — the profiles CLI no longer
 supports installs, so the profile lands there as pending until you click
@@ -43,7 +43,9 @@ fresh identifier and UUIDs, so each install provisions a cold account with
 no cached sync state — that is deliberate for the experiment loop, and
 `rake profile:install` sweeping the old profiles first is what keeps it
 cold. `profile:remove` finds them by scanning `profiles list` output for
-the pro-tacts prefix. Apple's device-management
+the pro-tacts prefix and a digest of `PRO_TACTS_HOSTNAME` (which it needs
+set too), so a dev sweep leaves a profile pointing at the deployment alone
+and prints the identifiers it left behind. Apple's device-management
 reference marks the CardDAV payload as allowing manual install, so no MDM is
 involved.
 
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 4b589b4..b699566 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -1,3 +1,4 @@
+require "digest"
 require "securerandom"
 
 require "nokogiri"
@@ -15,7 +16,8 @@ module ProTacts
   # identity follows the profile, so each install provisions a cold account
   # with no cached sync state — exactly what the experiment loop needs. The
   # cost is that reinstalling without removing first orphans the old account;
-  # rake profile:remove sweeps every profile carrying our prefix.
+  # rake profile:remove sweeps every profile carrying our prefix and the
+  # host it is configured for.
   #
   # The served route keeps that rule rather than deriving a stable identifier
   # from the account, which would make a reinstall a no-op: an identifier is
@@ -52,8 +54,8 @@ module ProTacts
     # and dropping it is untested.
     #: (hostname: String, username: String) -> String
     def self.render(hostname:, username:)
-      identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
       name = account_name
+      identifier = "#{IDENTIFIER_PREFIX}.#{host_digest(hostname)}.#{unique_hex}"
 
       builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |x|
         x.doc.create_internal_subset(
@@ -114,16 +116,53 @@ module ProTacts
       builder.to_xml
     end
 
-    # Picks our profile identifiers out of `profiles list` output so
-    # profile:remove can sweep every pro-tacts profile, not just the latest.
-    # Scans for the prefix anywhere in the output rather than assuming a
+    # The host as one identifier segment, so an installed profile says
+    # which server it provisions an account against. The host rather than
+    # the account name, which defaults to the same "pro-tacts" whoever
+    # rendered it, or the username, which is one login across both: two
+    # profiles pointing at the same host are the same account, and two
+    # pointing at different hosts never are.
+    #
+    # A digest rather than the host itself, which would read as a second
+    # dotted name inside a dotted identifier. Hiding nothing — the profile
+    # states the host a few keys down — so a short one is enough, and the
+    # sweep recomputes it from the host it is configured for. Downcased
+    # first, since a host differing only in case is the same server.
+    #: (String hostname) -> String
+    def self.host_digest(hostname)
+      # The message names the argument rather than PRO_TACTS_HOSTNAME: the
+      # rake tasks read that variable, /setup passes the request's host,
+      # and only the caller knows which it was.
+      if hostname.strip.empty?
+        raise ArgumentError, "hostname is #{hostname.inspect}, so the profile would name no server. " \
+          "Pass the host the app answers on, such as \"pro-tacts.example.ts.net\"."
+      end
+
+      Digest::SHA256.hexdigest(hostname.downcase)[0, 8] #: String
+    end
+
+    # Picks the identifiers of profiles provisioned against hostname out of
+    # `profiles list` output, so profile:remove sweeps every profile
+    # pointing at the server it is configured for and no others — a dev
+    # sweep leaves the profile the deployment installed alone. Pass
+    # hostname: nil for every pro-tacts profile whatever server it points
+    # at, including the ones installed before the host was part of the
+    # identifier.
+    #
+    # Scans for the identifier anywhere in the output rather than assuming a
     # key-value layout, since the listing format has changed across macOS
     # versions (key-value today, table under later releases).
-    #: (String list_output) -> Array[String]
-    def self.installed_identifiers(list_output)
+    #: (String list_output, hostname: String?) -> Array[String]
+    def self.installed_identifiers(list_output, hostname:)
+      tail = if hostname
+        /\.#{host_digest(hostname)}\.[\w-]+/
+      else
+        /[.-][\w.-]+/
+      end
+
       # A pattern with no groups scans to whole matches, which is
       # narrower than the signature of String#scan can say.
-      list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}-[\w.-]+/).uniq #: Array[String]
+      list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}#{tail}(?![\w.-])/).uniq #: Array[String]
     end
 
     #: () -> String
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
index df91453..004b2aa 100644
--- a/test/pro_tacts/test_profile.rb
+++ b/test/pro_tacts/test_profile.rb
@@ -77,32 +77,100 @@ class ProfileTest < Minitest::Test
     assert_equal 2, uuids.uniq.size
   end
 
+  # An identifier as a render writes one: the prefix, the host's digest,
+  # then the unique hex.
+  def identifier(hostname, hex = "20260818ab12")
+    "#{ProTacts::Profile::IDENTIFIER_PREFIX}.#{ProTacts::Profile.host_digest(hostname)}.#{hex}"
+  end
+
+  def payload_identifiers(xml)
+    xml.scan(%r{<key>PayloadIdentifier</key>\s*<string>([^<]+)</string>}).flatten
+  end
+
   def test_installed_identifiers_picks_out_pro_tacts_profiles
     list_output = <<~OUTPUT
       _admin-Profiles-1
-          identifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12
+          identifier: #{identifier("example.ts.net")}
 
       _admin-Profiles-2
           identifier: com.example.unrelated
 
       _admin-Profiles-3
+          identifier: #{identifier("example.ts.net", "20260818cd34")}
+    OUTPUT
+
+    assert_equal [identifier("example.ts.net"), identifier("example.ts.net", "20260818cd34")],
+      ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
+  end
+
+  # The sweep a dev session runs has to leave the profile the deployment
+  # installed alone: same prefix, another server.
+  def test_installed_identifiers_leaves_profiles_pointing_at_another_host
+    list_output = <<~OUTPUT
+          identifier: #{identifier("example.ts.net")}
+          identifier: #{identifier("localhost")}
+    OUTPUT
+
+    assert_equal [identifier("localhost")],
+      ProTacts::Profile.installed_identifiers(list_output, hostname: "localhost")
+    assert_equal [identifier("example.ts.net")],
+      ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
+  end
+
+  # What the sweep reports as left behind, including the profiles
+  # installed before the host was part of the identifier.
+  def test_installed_identifiers_without_a_hostname_finds_every_pro_tacts_profile
+    list_output = <<~OUTPUT
+          identifier: #{identifier("example.ts.net")}
+          identifier: #{identifier("localhost")}
           identifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818cd34
+          identifier: com.example.unrelated
     OUTPUT
 
     assert_equal [
-      "#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12",
+      identifier("example.ts.net"),
+      identifier("localhost"),
       "#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818cd34",
-    ], ProTacts::Profile.installed_identifiers(list_output)
+    ], ProTacts::Profile.installed_identifiers(list_output, hostname: nil)
+  end
+
+  # A sweep for the host a render points at finds the profile's own
+  # identifier and not the account payload's, which carries it as a
+  # prefix; a sweep for another host finds neither.
+  def test_a_render_is_swept_only_for_the_host_it_points_at
+    xml = render(hostname: "example.ts.net")
+    list_output = payload_identifiers(xml).map { "    identifier: #{it}\n" }.join
+
+    assert_equal payload_identifiers(xml).reject { it.end_with?(".account") },
+      ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
+    assert_empty ProTacts::Profile.installed_identifiers(list_output, hostname: "localhost")
+  end
+
+  # A host differing only in case is the same server, so one sweep takes
+  # profiles rendered under either spelling.
+  def test_installed_identifiers_reads_the_host_case_insensitively
+    list_output = "    identifier: #{identifier("example.ts.net")}\n"
+
+    assert_equal [identifier("example.ts.net")],
+      ProTacts::Profile.installed_identifiers(list_output, hostname: "Example.TS.net")
+  end
+
+  # A blank host names no server, and the profile it would render points
+  # nowhere, so the render is refused instead.
+  def test_a_blank_hostname_is_refused
+    error = assert_raises(ArgumentError) { render(hostname: "  ") }
+
+    assert_includes error.message, "hostname"
   end
 
   def test_installed_identifiers_reads_attribute_format_output
     list_output = <<~OUTPUT
-      alpha[1] attribute: profileIdentifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818155835720a9db
+      alpha[1] attribute: profileIdentifier: #{identifier("example.ts.net", "20260818155835720a9db")}
       There are 1 user configuration profiles installed for 'alpha'
     OUTPUT
 
-    assert_equal ["#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818155835720a9db"],
-      ProTacts::Profile.installed_identifiers(list_output)
+    assert_equal [identifier("example.ts.net", "20260818155835720a9db")],
+      ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
   end
 
   def test_installed_identifiers_reads_table_format_output
@@ -110,24 +178,24 @@ class ProfileTest < Minitest::Test
       Profiles:
           identifier                             display name
           ------------------------------------   --------------
-          #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12   pro-tacts CardDAV
+          #{identifier("example.ts.net")}   pro-tacts CardDAV
           com.example.unrelated                  Work
       OUTPUT
 
-    assert_equal ["#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12"],
-      ProTacts::Profile.installed_identifiers(list_output)
+    assert_equal [identifier("example.ts.net")],
+      ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
   end
 
   def test_installed_identifiers_ignores_longer_identifiers_containing_the_prefix
-    list_output = "com.example.#{ProTacts::Profile::IDENTIFIER_PREFIX}-fake\n"
+    list_output = "com.example.#{identifier("example.ts.net")}\n"
 
-    assert_empty ProTacts::Profile.installed_identifiers(list_output)
+    assert_empty ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
   end
 
   def test_installed_identifiers_is_empty_without_ours
     list_output = "  identifier: com.example.unrelated\n"
 
-    assert_empty ProTacts::Profile.installed_identifiers(list_output)
+    assert_empty ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
   end
 
   def test_escapes_xml_in_field_values