Derive etags and ctags from file state
Mtime is the obvious key and the wrong one: git rewrites it on every
checkout, and a reformat-only edit changes a file's bytes without
changing the card. Hashing the rendered vCard moves the etag exactly
when what the client downloads moves.

Assisted-by: Claude Opus 5 via Claude Code
change smtwuyrrwmlvlmqopulzqprtsotpqomp
commit e0c32942855f5ac28fce67fa249966e1f209f89e
author Alpha Chen <alpha@kejadlen.dev>
date
parent vmkkwtks
diff --git a/README.md b/README.md
index 262b469..05dd052 100644
--- a/README.md
+++ b/README.md
@@ -30,8 +30,10 @@ email "john@example.com"
 macOS Contacts displays them over Tailscale serve as of 2026-08-14, so
 later work has a known-good baseline to change. See
 `docs/plans/2026-08-12-one-card-on-macos.md` for what that milestone
-established. Still placeholders: etags and ctags are constants rather
-than derived from file state, and there is no authentication yet.
+established. Etags, the ctag, and the sync token are derived from file
+state — a contact's etag hashes its rendered vCard, and the collection
+tags hash the membership — so an edit on disk reaches synced clients on
+their next poll. There is no authentication yet.
 
 ## The minimal set macOS Contacts needs
 
diff --git a/lib/pro_tacts/addressbook.rb b/lib/pro_tacts/addressbook.rb
new file mode 100644
index 0000000..51e11be
--- /dev/null
+++ b/lib/pro_tacts/addressbook.rb
@@ -0,0 +1,30 @@
+require "digest"
+
+require "pro_tacts/contact"
+
+module ProTacts
+  # The address book collection: every contact under the contacts
+  # directory, plus the collection-wide state derived from those members.
+  # The ctag changes when any card is added, removed, or changed and
+  # nothing else, so a client comparing two of them learns whether a
+  # resync is needed — never what changed.
+  class Addressbook < Data.define(:contacts)
+    def self.load(directory)
+      new(contacts: Contact.all(directory))
+    end
+
+    # Sorting the id-and-etag lines makes the value independent of
+    # directory listing order while staying sensitive to membership and
+    # content.
+    def ctag
+      Digest::SHA256.hexdigest(contacts.map { "#{it.id} #{it.etag}" }.sort.join("\n"))
+    end
+
+    # Sync tokens are opaque to the client (RFC 6578 section 3); the URI
+    # form is conventional. Built on the ctag so a client polling either
+    # one sees changes at the same points.
+    def sync_token
+      "http://pro-tacts/sync/#{ctag}"
+    end
+  end
+end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 9f47cbf..29e0c1a 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -1,3 +1,4 @@
+require "digest"
 require "kdl"
 require "pathname"
 
@@ -7,7 +8,14 @@ module ProTacts
   # A contact parsed from one KDL file under the contacts directory; the
   # file is a bare KDL document, and the filename is the id, which maps
   # to the vCard UID (see docs/plans/2026-01-12-carddav-architecture.md).
-  class Contact < Data.define(:id, :vcard)
+  #
+  # The etag hashes the rendered vCard rather than the file's bytes or
+  # mtime, so it changes exactly when what the client downloads changes:
+  # git rewrites mtimes on every checkout, and a reformat-only edit
+  # changes the bytes without changing the card. It is stored in the
+  # entity-tag's quoted form (RFC 7232 section 2.3), which is what both
+  # the ETag header and getetag properties carry.
+  class Contact < Data.define(:id, :vcard, :etag)
     # Ids end up in paths and arrive from client-supplied hrefs, so a
     # filename outside this charset cannot be served.
     ID_FORMAT = /\A[\w-]+\z/
@@ -31,7 +39,8 @@ module ProTacts
       id = path.basename(".kdl").to_s
       raise ArgumentError, "invalid contact id: #{id}" unless id.match?(ID_FORMAT)
 
-      new(id:, vcard: VCard.render(KDL.parse(path.read), uid: id))
+      vcard = VCard.render(KDL.parse(path.read), uid: id)
+      new(id:, vcard:, etag: %("#{Digest::SHA256.hexdigest(vcard)}"))
     end
   end
 end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 1a22944..aa4d75e 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -7,7 +7,7 @@ require "nokogiri"
 require "roda"
 
 require "pro_tacts/debug_logger"
-require "pro_tacts/contact"
+require "pro_tacts/addressbook"
 require "roda/plugins/dav_verbs"
 
 module ProTacts
@@ -29,12 +29,6 @@ module ProTacts
       "Not Found"
     end
 
-    # Placeholder until etags and ctags are derived from file state; the
-    # constants only need to be present and stable within a session.
-    CONTACT_ETAG = %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2")
-    COLLECTION_CTAG = "ctag-2"
-    SYNC_TOKEN = "http://pro-tacts/sync/2"
-
     route do |r|
       r.is "" do
         r.propfind do
@@ -151,8 +145,8 @@ module ProTacts
                           <d:report><d:sync-collection/></d:report>
                         </d:supported-report>
                       </d:supported-report-set>
-                      <cs:getctag>#{COLLECTION_CTAG}</cs:getctag>
-                      <d:sync-token>#{SYNC_TOKEN}</d:sync-token>
+                      <cs:getctag>#{addressbook.ctag}</cs:getctag>
+                      <d:sync-token>#{addressbook.sync_token}</d:sync-token>
                     </d:prop>
                     <d:status>HTTP/1.1 200 OK</d:status>
                   </d:propstat>
@@ -161,7 +155,7 @@ module ProTacts
             end
 
             # Depth: 0 returns only collection, Depth: 1 includes members
-            members = depth == "0" ? "" : contacts.map { etag_response(it.id) }.join
+            members = depth == "0" ? "" : addressbook.contacts.map { etag_response(it) }.join
 
             <<~XML
               <?xml version="1.0" encoding="UTF-8"?>
@@ -185,16 +179,16 @@ module ProTacts
             if doc.root.name == "sync-collection"
               # The warm-sync ask is etag-only; a changed etag sends the
               # client back through multiget, so no address-data here.
-              responses = contacts.map { etag_response(it.id) }
+              responses = addressbook.contacts.map { etag_response(it) }
             else
               wants_cards = doc.xpath("//address-data").any?
 
               responses = doc.xpath("//href").map { it.text }.map { |requested|
                 id = requested[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1]
-                contact = id && contacts.find { it.id == id }
+                contact = id && addressbook.contacts.find { it.id == id }
 
                 if contact
-                  wants_cards ? card_response(contact) : etag_response(contact.id)
+                  wants_cards ? card_response(contact) : etag_response(contact)
                 else
                   missing_response(requested)
                 end
@@ -210,13 +204,13 @@ module ProTacts
           end
 
           r.get String do |filename|
-            contact = contacts.find { it.id == filename.delete_suffix(".vcf") }
+            contact = addressbook.contacts.find { it.id == filename.delete_suffix(".vcf") }
 
             # No match falls through to the empty-body 404 that the
             # not_found handler fills in.
             if contact
               response["Content-Type"] = "text/vcard; charset=utf-8"
-              response["ETag"] = CONTACT_ETAG
+              response["ETag"] = contact.etag
               contact.vcard
             end
           end
@@ -226,24 +220,25 @@ module ProTacts
 
     private
 
-    # Parsed once per request — Roda builds a fresh app instance for each
-    # one — with the directory coming from config. Caching belongs with
-    # real etags.
-    def contacts
-      @contacts ||= Contact.all(ProTacts.config.contacts_dir)
+    # Loaded once per request — Roda builds a fresh app instance for each
+    # one — with the directory coming from config. Real etags and ctags
+    # make an mtime-keyed cache possible, but re-parsing a family address
+    # book per request is cheap and can never serve a stale change tag.
+    def addressbook
+      @addressbook ||= Addressbook.load(ProTacts.config.contacts_dir)
     end
 
     def contact_href(id)
       "/dav/addressbook/#{id}.vcf"
     end
 
-    def etag_response(id)
+    def etag_response(contact)
       <<~XML
         <d:response>
-          <d:href>#{contact_href(id)}</d:href>
+          <d:href>#{contact_href(contact.id)}</d:href>
           <d:propstat>
             <d:prop>
-              <d:getetag>#{CONTACT_ETAG}</d:getetag>
+              <d:getetag>#{contact.etag}</d:getetag>
             </d:prop>
             <d:status>HTTP/1.1 200 OK</d:status>
           </d:propstat>
@@ -257,7 +252,7 @@ module ProTacts
           <d:href>#{contact_href(contact.id)}</d:href>
           <d:propstat>
             <d:prop>
-              <d:getetag>#{CONTACT_ETAG}</d:getetag>
+              <d:getetag>#{contact.etag}</d:getetag>
               <card:address-data>#{xml_escape(contact.vcard.chomp)}</card:address-data>
             </d:prop>
             <d:status>HTTP/1.1 200 OK</d:status>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 0d4b71c..e0a0a55 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -16,8 +16,8 @@ Content-Type: text/xml
           <d:report><d:sync-collection/></d:report>
         </d:supported-report>
       </d:supported-report-set>
-      <cs:getctag>ctag-2</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/2</d:sync-token>
+      <cs:getctag>cfe366324ca3c5bb85010587e5fa778044ee67b5480e45441b562c662b7d1dc5</cs:getctag>
+      <d:sync-token>http://pro-tacts/sync/cfe366324ca3c5bb85010587e5fa778044ee67b5480e45441b562c662b7d1dc5</d:sync-token>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index 91d0b4f..cd9cb4a 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -8,7 +8,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/07-report-multiget/response b/test/fixtures/macos-exchange/07-report-multiget/response
index 2528ee4..a023f27 100644
--- a/test/fixtures/macos-exchange/07-report-multiget/response
+++ b/test/fixtures/macos-exchange/07-report-multiget/response
@@ -7,7 +7,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
       <card:address-data>BEGIN:VCARD
 VERSION:3.0
 N:Contact;Test;;;
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index 7ab90e2..e7b9bc8 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -7,7 +7,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index e1bcf12..9be0034 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -16,8 +16,8 @@ Content-Type: text/xml
           <d:report><d:sync-collection/></d:report>
         </d:supported-report>
       </d:supported-report-set>
-      <cs:getctag>ctag-2</cs:getctag>
-      <d:sync-token>http://pro-tacts/sync/2</d:sync-token>
+      <cs:getctag>cfe366324ca3c5bb85010587e5fa778044ee67b5480e45441b562c662b7d1dc5</cs:getctag>
+      <d:sync-token>http://pro-tacts/sync/cfe366324ca3c5bb85010587e5fa778044ee67b5480e45441b562c662b7d1dc5</d:sync-token>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
@@ -27,7 +27,7 @@ Content-Type: text/xml
   <d:href>/dav/addressbook/AB12C345-6789-0DEF-1234-567890ABCDEF.vcf</d:href>
   <d:propstat>
     <d:prop>
-      <d:getetag>"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"</d:getetag>
+      <d:getetag>"ce8bef6ad9abb83e81da4d5aceb7999b43781726f148e2607ed853532f9b8db6"</d:getetag>
     </d:prop>
     <d:status>HTTP/1.1 200 OK</d:status>
   </d:propstat>
diff --git a/test/pro_tacts/test_addressbook.rb b/test/pro_tacts/test_addressbook.rb
new file mode 100644
index 0000000..12a9c80
--- /dev/null
+++ b/test/pro_tacts/test_addressbook.rb
@@ -0,0 +1,58 @@
+require_relative "../test_helper"
+
+require "pathname"
+require "tmpdir"
+
+require "pro_tacts/addressbook"
+
+class AddressbookTest < Minitest::Test
+  def with_contacts(files)
+    Dir.mktmpdir do |dir|
+      contacts_dir = Pathname.new(dir) / "contacts"
+      Dir.mkdir(contacts_dir)
+      files.each { |name, content| File.write(contacts_dir / name, content) }
+      yield contacts_dir
+    end
+  end
+
+  def ctag(contacts_dir)
+    ProTacts::Addressbook.load(contacts_dir).ctag
+  end
+
+  def test_the_ctag_is_stable_across_loads
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+      assert_equal ctag(contacts_dir), ctag(contacts_dir)
+    end
+  end
+
+  def test_the_ctag_moves_with_a_cards_content
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+      before = ctag(contacts_dir)
+      File.write(contacts_dir / "aiden.kdl", "name \"Aiden Smith\"")
+
+      refute_equal before, ctag(contacts_dir)
+    end
+  end
+
+  def test_the_ctag_moves_with_membership_both_ways
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+      alone = ctag(contacts_dir)
+
+      File.write(contacts_dir / "znorth.kdl", "name \"Zed\"")
+      refute_equal alone, ctag(contacts_dir)
+
+      File.delete(contacts_dir / "znorth.kdl")
+      assert_equal alone, ctag(contacts_dir)
+    end
+  end
+
+  def test_the_sync_token_moves_with_the_ctag
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+      token = ProTacts::Addressbook.load(contacts_dir).sync_token
+
+      File.write(contacts_dir / "aiden.kdl", "name \"Aiden Smith\"")
+
+      refute_equal token, ProTacts::Addressbook.load(contacts_dir).sync_token
+    end
+  end
+end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index bca93a5..50486a9 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -1,5 +1,6 @@
 require_relative "../test_helper"
 
+require "digest"
 require "pathname"
 require "tmpdir"
 
@@ -32,6 +33,24 @@ class ContactTest < Minitest::Test
     end
   end
 
+  def test_the_etag_hashes_the_rendered_card
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |directory|
+      contact = ProTacts::Contact.all(directory).first
+
+      assert_equal %("#{Digest::SHA256.hexdigest(contact.vcard)}"), contact.etag
+    end
+  end
+
+  def test_the_etag_is_stable_across_parses_and_moves_with_content
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |directory|
+      etag = ProTacts::Contact.all(directory).first.etag
+      assert_equal etag, ProTacts::Contact.all(directory).first.etag
+
+      File.write(directory / "aiden.kdl", "name \"Aiden Smith\"")
+      refute_equal etag, ProTacts::Contact.all(directory).first.etag
+    end
+  end
+
   def test_an_empty_directory_lists_no_contacts
     with_contacts({}) do |directory|
       assert_empty ProTacts::Contact.all(directory)
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 7fc855c..c8185ce 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -1,5 +1,6 @@
 
 require_relative "../test_helper"
+require "digest"
 require "pathname"
 require "rack/test"
 require "tmpdir"
@@ -71,7 +72,7 @@ class WebTest < Minitest::Test
 
     assert_equal 200, last_response.status
     assert_equal "text/vcard; charset=utf-8", last_response["Content-Type"]
-    assert_equal %("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"), last_response["ETag"]
+    assert_equal %("#{Digest::SHA256.hexdigest(last_response.body)}"), last_response["ETag"]
     assert_includes last_response.body, "BEGIN:VCARD"
     assert_includes last_response.body, "END:VCARD"
   end
@@ -120,7 +121,7 @@ class WebTest < Minitest::Test
         "PRO_TACTS_DATA_DIR" => dir,
       })
       begin
-        yield
+        yield contacts_dir
       ensure
         ProTacts.config = original
       end
@@ -213,4 +214,36 @@ class WebTest < Minitest::Test
       refute_includes last_response.body, "address-data"
     end
   end
+
+  def test_etags_agree_across_listing_multiget_and_get
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do
+      request "/dav/addressbook/", method: "PROPFIND", "HTTP_DEPTH" => "1", input: etag_only_propfind
+      etag = last_response.body[%r{<d:getetag>(.+)</d:getetag>}, 1]
+
+      get "/dav/addressbook/aiden.vcf"
+      assert_equal etag, last_response["ETag"]
+
+      request "/dav/addressbook/", method: "REPORT", input: multiget("aiden")
+      assert_includes last_response.body, "<d:getetag>#{etag}</d:getetag>"
+    end
+  end
+
+  def test_a_changed_card_changes_its_etag_and_the_collection_tags
+    with_contacts({"aiden.kdl" => "name \"Aiden\""}) do |contacts_dir|
+      read_tags = lambda {
+        request "/dav/addressbook/", method: "PROPFIND"
+        [last_response.body[%r{<d:getetag>(.+)</d:getetag>}, 1],
+         last_response.body[%r{<cs:getctag>(.+)</cs:getctag>}, 1],
+         last_response.body[%r{<d:sync-token>(.+)</d:sync-token>}, 1]]
+      }
+
+      before = read_tags.call
+      assert_equal before, read_tags.call # stable across requests
+
+      File.write(contacts_dir / "aiden.kdl", "name \"Aiden Smith\"")
+      after = read_tags.call
+
+      before.zip(after).each { refute_equal it.first, it.last }
+    end
+  end
 end