Give each rendered profile a fresh identity
The account follows the profile identifier, and the fixed id let
cached sync state survive remove/reinstall (round 3: the client kept
the old card and skipped fetching). Every render now gets a unique
identifier and UUIDs, so each install provisions a cold account;
profile:install renders directly since mtimes cannot express identity
staleness, and profile:remove sweeps every profile with our prefix
from profiles list output.

Assisted-by: GLM 5.2 via pi
change pyuzrvmvtmkwqwkqmyppsmvyxrvktvrp
commit 68a3ce7ecbf46b221513607ad90612d993ffc65e
author Alpha Chen <alpha@kejadlen.dev>
date
parent vzwwqwlq
diff --git a/Rakefile b/Rakefile
index 5195d22..d8321c8 100644
--- a/Rakefile
+++ b/Rakefile
@@ -33,16 +33,29 @@ file "carddav.mobileconfig" => "lib/pro_tacts/profile.rb" do |task|
 end
 
 namespace :profile do
-  desc "Stage the configuration profile and open Settings → Profiles; click Install there"
-  task install: "carddav.mobileconfig" do |task|
-    sh "open", task.prerequisites.first
+  # A fresh identity per install is the point, so this renders directly
+  # instead of going through the mtime-based file task.
+  desc "Stage a fresh configuration profile; approve it in System Settings → Profiles"
+  task :install do
+    require "pro_tacts/profile"
+
+    File.write("carddav.mobileconfig", ProTacts::Profile.render(
+      hostname: ENV.fetch("PRO_TACTS_HOSTNAME")
+    ))
+    sh "open", "carddav.mobileconfig"
     sh "open", "x-apple.systempreferences:com.apple.preferences.configurationprofiles"
   end
 
-  desc "Remove the configuration profile"
+  desc "Remove every installed pro-tacts configuration profile"
   task :remove do
     require "pro_tacts/profile"
-    sh "profiles", "remove", "-identifier", ProTacts::Profile::PAYLOAD_IDENTIFIER
+
+    identifiers = ProTacts::Profile.installed_identifiers(`profiles list`)
+    if identifiers.empty?
+      puts "No pro-tacts profiles found; remove by hand in System Settings → Profiles if one lingers."
+    else
+      identifiers.each { |identifier| sh "profiles", "remove", "-identifier", identifier }
+    end
   end
 end
 
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
index a65cc1c..8976690 100644
--- a/docs/macos-contacts.md
+++ b/docs/macos-contacts.md
@@ -37,9 +37,12 @@ same Settings pane.
 
 The profile carries the hostname, fixed dev credentials, and SSL —
 `CardDAVPrincipalURL` is deliberately omitted so the
-account gets an empty Server Path, exercising discovery. Fixed payload
-identifiers mean a reinstall replaces the account in place, and removal is
-what resets the client's cached discovery results. Apple's device-management
+account gets an empty Server Path, exercising discovery. Every render gets a
+fresh identifier and UUIDs, so each install provisions a cold account with
+no cached sync state — that is deliberate for the experiment loop. The flip
+side: installing without removing first orphans the previous account, so
+`rake profile:remove` sweeps every profile carrying the pro-tacts prefix
+by parsing `profiles list`. 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 c689d57..3ca188e 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -4,21 +4,39 @@ require "nokogiri"
 
 module ProTacts
   # Renders the configuration profile that provisions the pro-tacts CardDAV
-  # account on macOS, so the resync loop is `rake profile` plus one
-  # `profiles install` instead of the Internet Accounts dance. Payload keys
-  # per Apple's Device Management reference for com.apple.carddav.account.
+  # account on macOS, so the resync loop is one rake command instead of the
+  # Internet Accounts dance. Payload keys per Apple's Device Management
+  # reference for com.apple.carddav.account.
   #
-  # UUIDs are fixed so reinstalling replaces the profile in place and
-  # `profiles remove -identifier` always targets the same one.
+  # Every render carries a fresh identifier and fresh UUIDs: the account
+  # 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.
   class Profile
-    PAYLOAD_IDENTIFIER = "dev.kejadlen.pro-tacts.carddav"
-    TOP_LEVEL_UUID = "6F1E2D3C-4B5A-4E7F-8C9D-0A1B2C3D4E5F"
-    PAYLOAD_UUID = "7A2F3E4D-5C6B-4F80-9DAE-1B2C3D4E5F6A"
+    IDENTIFIER_PREFIX = "dev.kejadlen.pro-tacts.carddav"
+    HEX = "0123456789abcdef"
 
     # Username and password are a throwaway fictional pair, inlined in the
     # template. Real auth is its own backlog task.
     def self.render(hostname:)
-      template % { hostname: escape(hostname) }
+      identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
+
+      template % {
+        hostname: escape(hostname),
+        identifier:,
+        account_identifier: "#{identifier}.account",
+        top_level_uuid: uuid,
+        payload_uuid: uuid
+      }
+    end
+
+    # Picks our profile identifiers out of `profiles list` output so
+    # profile:remove can sweep every pro-tacts profile, not just the latest.
+    def self.installed_identifiers(list_output)
+      list_output.scan(/^\s*identifier:\s*(\S+)/).flatten
+        .select { |identifier| identifier.start_with?(IDENTIFIER_PREFIX) }
+        .uniq
     end
 
     def self.template
@@ -35,9 +53,9 @@ module ProTacts
               <key>PayloadVersion</key>
               <integer>1</integer>
               <key>PayloadIdentifier</key>
-              <string>#{PAYLOAD_IDENTIFIER}.account</string>
+              <string>%{account_identifier}</string>
               <key>PayloadUUID</key>
-              <string>#{PAYLOAD_UUID}</string>
+              <string>%{payload_uuid}</string>
               <key>PayloadDisplayName</key>
               <string>pro-tacts</string>
               <key>PayloadOrganization</key>
@@ -57,7 +75,7 @@ module ProTacts
           <key>PayloadDisplayName</key>
           <string>pro-tacts CardDAV</string>
           <key>PayloadIdentifier</key>
-          <string>#{PAYLOAD_IDENTIFIER}</string>
+          <string>%{identifier}</string>
           <key>PayloadOrganization</key>
           <string>pro-tacts</string>
           <key>PayloadRemovalDisallowed</key>
@@ -67,7 +85,7 @@ module ProTacts
           <key>PayloadType</key>
           <string>Configuration</string>
           <key>PayloadUUID</key>
-          <string>#{TOP_LEVEL_UUID}</string>
+          <string>%{top_level_uuid}</string>
           <key>PayloadVersion</key>
           <integer>1</integer>
         </dict>
@@ -81,5 +99,13 @@ module ProTacts
     def self.escape(text)
       text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
     end
+
+    def self.unique_hex
+      "#{Time.now.utc.strftime('%Y%m%d%H%M%S%L')}#{rand(1 << 16).to_s(16)}"
+    end
+
+    def self.uuid
+      [8, 4, 4, 4, 12].map { |n| Array.new(n) { HEX[rand(16)] }.join }.join("-")
+    end
   end
 end
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
index bab2de6..0fd96e2 100644
--- a/test/pro_tacts/test_profile.rb
+++ b/test/pro_tacts/test_profile.rb
@@ -33,9 +33,43 @@ class ProfileTest < Minitest::Test
     refute_includes render, "CardDAVPrincipalURL"
   end
 
-  def test_identifiers_are_stable_across_renders
-    assert_equal render, render
-    assert_includes render, ProTacts::Profile::PAYLOAD_IDENTIFIER
+  def test_identifiers_are_fresh_per_render
+    first, second = render, render
+
+    refute_equal first, second
+    assert_includes first, ProTacts::Profile::IDENTIFIER_PREFIX
+    assert_includes second, ProTacts::Profile::IDENTIFIER_PREFIX
+  end
+
+  def test_uuids_are_well_formed
+    xml = render
+
+    uuids = xml.scan(%r{<string>([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})</string>})
+    assert_equal 2, uuids.uniq.size
+  end
+
+  def test_installed_identifiers_picks_out_pro_tacts_profiles
+    list_output = <<~OUTPUT
+      _admin-Profiles-1
+          identifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12
+
+      _admin-Profiles-2
+          identifier: com.example.unrelated
+
+      _admin-Profiles-3
+          identifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818cd34
+    OUTPUT
+
+    assert_equal [
+      "#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12",
+      "#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818cd34"
+    ], ProTacts::Profile.installed_identifiers(list_output)
+  end
+
+  def test_installed_identifiers_is_empty_without_ours
+    list_output = "  identifier: com.example.unrelated\n"
+
+    assert_empty ProTacts::Profile.installed_identifiers(list_output)
   end
 
   def test_escapes_xml_in_field_values