Refuse sync tokens from a replaced database
Every deployed client takes one 410 and resyncs at its first sync
after this lands: an old token names no database, so there is nothing
to check it against.

Assisted-by: GLM-5.3 via pi
change nzwzmsskmxumwqxwylotxurnqyrkruyw
commit c75bf009f2678a6c71244759517fe3e190be0924
author Alpha Chen <alpha@kejadlen.dev>
date
parent 06f4b04e
diff --git a/Rakefile b/Rakefile
index 40a5a93..04c1f07 100644
--- a/Rakefile
+++ b/Rakefile
@@ -59,7 +59,7 @@ task :fixtures do
     # deleting a card, and the recording after it must not start from
     # what that one left behind.
     ExchangeFixtures.all.each do |recording|
-      store = FixtureData.install(data_dir / recording.directory.basename)
+      store = FixtureData.install(data_dir / recording.directory.basename, genesis: ExchangeFixtures::GENESIS)
       ProTacts::Web.store = store
       recording.record_responses(ProTacts::Web)
       store.close
diff --git a/docs/plans/2026-09-24-sync-tokens-name-their-database.md b/docs/plans/2026-09-24-sync-tokens-name-their-database.md
new file mode 100644
index 0000000..1fa8de3
--- /dev/null
+++ b/docs/plans/2026-09-24-sync-tokens-name-their-database.md
@@ -0,0 +1,75 @@
+# Sync tokens name their database
+
+2026-09-24. A sync token now carries the id of the database that minted
+it, so a token held against a replaced database is refused with the
+410 that makes a client resync, rather than answered with a delta
+computed against a history it never saw.
+
+## What went wrong
+
+The token was `http://pro-tacts/sync/<sequence>/<digest>` — the change
+log's sequence and the book's digest, and nothing that said which
+database minted it. Sequences are per-database counters, so any other
+database with overlapping numbers would accept it: `rake dev` reseeds a
+throwaway database on every start, to the same ctag every time, and a
+dump restored onto the same machine replays a truncated history. In
+both, a device's token at or below the new latest sequence passed the
+route's guard and was answered with a delta from a log it had never
+seen. Nothing errored. The divergence is silent and permanent: the new
+log holds no entries for cards the old one deleted, so a client never
+hears those removals and keeps serving them out of its cache forever.
+
+This was found, not theorized: the per-user-books scenario testing
+(2026-09-16, task `mny`) watched a client token from an earlier dev
+session validate against a reseeded database and said so in a comment
+— "a per-database value in the token would 410 it."
+
+## The token
+
+The spelling is now `http://pro-tacts/sync/<sequence>/<digest>/<database>`,
+where the database half is `Store#database_id`: the first sixteen hex
+digits of the SHA-256 of the change log's first entry — its `created_at`
+alone. `SyncToken.read` takes the id, so a token not carrying this
+database's gets the 410 with `DAV:valid-sync-token` the route already
+sent (RFC 6578 section 3.2, marshalled per RFC 4918 section 16), and
+the client takes the bootstrap path both clients were proven to walk
+(task `mny`, scenario 6).
+
+The id is derived, not stored. A reseed or a restore writes a new
+first entry at a new moment, so every replaced database refuses every
+token the old one issued. A live database's first entry never moves:
+the log is append-only, no write touches a row's `created_at`, and
+`Store#reid` — the one writer that rewrites a log row — moves only its
+`card_id`, which is why the stamp is the digest's whole input.
+
+Rejected on the way here:
+
+- A minted id in a one-row table — correct, but a seventh kind of
+  state: a migration, a mint-on-open, and a fact to keep out of the
+  dump, all heavier than the bug it closes.
+- The hostname, configured or off the request — it names the
+  deployment, not the database, and both scenarios above happen on one
+  machine under one name. It would also refuse the same database
+  reached under a second name, a resync nothing asked for.
+- A digest of the whole log — O(history) on every poll, and macOS
+  polls.
+
+## The costs
+
+Every deployed device 410s once, at its first sync after this lands,
+and bootstraps. That is unavoidable, not merely cheap: an old token
+names no database, so there is nothing to check it against. Two edges
+are accepted with it: a token taken from a book that was empty 410s
+once after the first card lands, a resync of nothing; and two
+databases first written within the same millisecond would share an id —
+a coincidence no real replacement fits, and the price of deriving the
+id rather than minting one.
+
+## The fixtures
+
+Recorded responses embed the token, so a replay book must answer as
+the same database every run: `FixtureData.install(genesis:)` pins its
+first change's stamp (`ExchangeFixtures::GENESIS`, year 2000 on
+purpose), and the replay and `rake fixtures` both pass it. `rake dev`
+seeds unpinned, so every dev start is a fresh id — which is the
+original observation, now the behavior.
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 304d1eb..fba4068 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -1,4 +1,5 @@
 require "date"
+require "digest"
 require "json"
 require "pathname"
 require "sequel"
@@ -261,6 +262,25 @@ module ProTacts
       change_log.order(Sequel.desc(:sequence)).first&.fetch(:sequence).to_i
     end
 
+    # The id a sync token names this database by: a digest of the
+    # change log's first entry — the write every token's sequences
+    # count from, and the one row nothing ever rewrites. #reid can move
+    # its card_id, so the stamp is the digest's whole input; no write
+    # touches a log row's created_at. Derived rather than stored
+    # (docs/plans/2026-09-24-sync-tokens-name-their-database.md), so a
+    # reseed or a dump restored — a new first entry at a new moment —
+    # makes every token the old database issued stop reading, and the
+    # route answers the 410 rather than a delta against a history the
+    # client never saw. An empty log digests nothing, so a token taken
+    # from a book that was empty 410s once after its first card lands:
+    # a resync of nothing.
+    #: () -> String
+    def database_id
+      first = change_log.order(:sequence).first
+      stamp = first.nil? ? "" : first.fetch(:created_at).to_s
+      Digest::SHA256.hexdigest(stamp)[0, 16].to_s
+    end
+
     # Birthdays that land on a coming calendar day, in arrival order:
     # today's first, and one already passed this year wrapped onto next
     # year rather than dropped — the wrap that keeps "upcoming" a
diff --git a/lib/pro_tacts/sync_token.rb b/lib/pro_tacts/sync_token.rb
index d321f94..671701a 100644
--- a/lib/pro_tacts/sync_token.rb
+++ b/lib/pro_tacts/sync_token.rb
@@ -1,12 +1,17 @@
 require "digest"
 
 module ProTacts
-  # A sync token as this server spells one: the ctag it was minted at
-  # and the book it was minted for, in a URI. Opaque to the client
-  # (RFC 6578 section 3) and the URI form is conventional, but not
-  # opaque to this server, which has to tell its own token from one
-  # another book minted — the one a rename leaves a user holding
-  # included (docs/plans/2026-09-12-per-user-books.md, "The wire").
+  # A sync token as this server spells one: the ctag it was minted at,
+  # the book it was minted for, and the database that minted it, in a
+  # URI. Opaque to the client (RFC 6578 section 3) and the URI form is
+  # conventional, but not opaque to this server, which has to tell its
+  # own token from one another book minted — the one a rename leaves a
+  # user holding included (docs/plans/2026-09-12-per-user-books.md,
+  # "The wire") — and from one another database minted: a reseed or a
+  # dump restored repeats the sequences, and the id is what refuses
+  # that token rather than answering a delta against a history it
+  # never saw
+  # (docs/plans/2026-09-24-sync-tokens-name-their-database.md).
   # Built on the ctag so that a client polling either one sees changes
   # at the same points.
   module SyncToken
@@ -19,18 +24,19 @@ module ProTacts
       Digest::SHA256.hexdigest(login)[0, 16].to_s
     end
 
-    #: (String ctag, String book) -> String
-    def self.mint(ctag, book)
-      "#{PREFIX}#{ctag}/#{book}"
+    #: (String ctag, String book, String database_id) -> String
+    def self.mint(ctag, book, database_id)
+      "#{PREFIX}#{ctag}/#{book}/#{database_id}"
     end
 
-    # The sequence a token carries, or nil for one this book did not
-    # mint — another book's, a rename's leftover, or a string that is
-    # no token of ours at all. Whether the sequence is one the change
-    # log can still answer from is the caller's to judge.
-    #: (String token, String book) -> Integer?
-    def self.read(token, book)
-      token[%r{\A#{Regexp.escape(PREFIX)}(\d+)/#{Regexp.escape(book)}\z}, 1]&.to_i
+    # The sequence a token carries, or nil for one this database did
+    # not mint — another database's (a reseed's, a restore's), another
+    # book's, a rename's leftover, or a string that is no token of
+    # ours at all. Whether the sequence is one the change log can
+    # still answer from is the caller's to judge.
+    #: (String token, String book, String database_id) -> Integer?
+    def self.read(token, book, database_id)
+      token[%r{\A#{Regexp.escape(PREFIX)}(\d+)/#{Regexp.escape(book)}/#{Regexp.escape(database_id)}\z}, 1]&.to_i
     end
   end
 end
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index 8af8cb8..bfbbc97 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -162,8 +162,9 @@ module ProTacts
             # The warm-sync ask is etag-only; a changed etag sends the
             # client back through multiget, so no address-data here.
             #
-            # The token carries the change log's sequence and whose book
-            # it was issued for (SyncToken), and the delta is the
+            # The token carries the change log's sequence, whose book
+            # it was issued for, and which database issued it
+            # (SyncToken), and the delta is the
             # log after the sequence, answered from the requester's book
             # as it is now: a card that left it answers as removed
             # (section 3.5.2), and so does one that never was in it,
@@ -190,7 +191,7 @@ module ProTacts
                   etag_response(d, it)
                 end
               end
-            elsif (sequence = SyncToken.read(token, book_digest)) && sequence <= store.latest_sequence
+            elsif (sequence = SyncToken.read(token, book_digest, store.database_id)) && sequence <= store.latest_sequence
               net = store.changes(after: sequence).map { it.card_id }.uniq
               multistatus("card", sync_token:) do |d|
                 net.each do |id|
@@ -199,8 +200,9 @@ module ProTacts
                 end
               end
             else
-              # A token this server never issued, one issued for another
-              # book, or one naming a state past the present: the
+              # A token this server never issued, one another
+              # database or book minted, or one naming a state past
+              # the present: the
               # section's DAV:valid-sync-token
               # precondition, marshalled per RFC 4918 section 16. 410
               # rather than 403 because its fallback is a full resync —
@@ -346,10 +348,12 @@ module ProTacts
     end
 
     # This request's token, minted at the ctag read above so that the
-    # one served here and the one a delta is measured from agree.
+    # one served here and the one a delta is measured from agree, and
+    # for the database this store serves, so no other's client can
+    # spend it (Store#database_id).
     #: () -> String
     def sync_token
-      SyncToken.mint(ctag, book_digest)
+      SyncToken.mint(ctag, book_digest, store.database_id)
     end
 
     #: () -> String
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 21307b5..ce28b48 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -182,8 +182,14 @@ module FixtureData
   end
 
   # Builds the database and returns a store still open on it, for the
-  # caller to hand to the app the way config.ru does.
-  def self.install(directory)
+  # caller to hand to the app the way config.ru does. `genesis` pins the
+  # first change's stamp, and with it the database id its sync tokens
+  # embed (Store#database_id): a recorded session's responses are byte
+  # for byte the replay's only if both books answer as the same
+  # database, and a real seed moment never is twice. Unpinned by
+  # default — `rake dev` reseeding is what a fresh id per start is for
+  # (ExchangeFixtures::GENESIS for the pinned spelling).
+  def self.install(directory, genesis: nil)
     directory = Pathname.new(directory)
     FileUtils.rm_rf(directory)
     FileUtils.mkdir_p(directory)
@@ -193,6 +199,7 @@ module FixtureData
     seed.each do |id, card|
       store.put(id, ProTacts::VCard.new(card))
     end
+    database(store)[:changes].where(sequence: 1).update(created_at: genesis) if genesis
     backdate(store, seed.keys)
     seed_groups(store)
     store
diff --git a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
index 9257e90..098b4af 100644
--- a/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
+++ b/test/fixtures/ios-exchange/02-propfind-addressbook-ctag/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
           </d:supported-report>
         </d:supported-report-set>
         <cs:getctag>22</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674</d:sync-token>
+        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index 8313cd5..0ea83f5 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
           </d:supported-report>
         </d:supported-report-set>
         <cs:getctag>22</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674</d:sync-token>
+        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
index 9257e90..098b4af 100644
--- a/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
+++ b/test/fixtures/macos-exchange/05-propfind-addressbook-ctag/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
           </d:supported-report>
         </d:supported-report-set>
         <cs:getctag>22</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674</d:sync-token>
+        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index 8313cd5..0ea83f5 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -19,7 +19,7 @@ Content-Type: text/xml
           </d:supported-report>
         </d:supported-report-set>
         <cs:getctag>22</cs:getctag>
-        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674</d:sync-token>
+        <d:sync-token>http://pro-tacts/sync/22/336e00f5e1ec2674/6ce9dc47db17f26e</d:sync-token>
         <d:current-user-privilege-set>
           <d:privilege>
             <d:read/>
diff --git a/test/fixtures/macos-exchange/README.md b/test/fixtures/macos-exchange/README.md
index 8b93211..06c3502 100644
--- a/test/fixtures/macos-exchange/README.md
+++ b/test/fixtures/macos-exchange/README.md
@@ -38,8 +38,11 @@ session received.
 
 Step 12 is a 410 the client really received, captured 2026-09-16 under
 `rake dev` with the dev login switched so the token's digest named
-another login's book. Its replay refuses it for the same reason: the
-digest is not the replay login's. After the 410, macOS sent a ctag
+another login's book. Its replay refuses it one step earlier: the
+recorded token predates tokens naming their database
+(docs/plans/2026-09-24-sync-tokens-name-their-database.md), so no part
+of it names the replay book's database — the same 410 either way. After
+the 410, macOS sent a ctag
 PROPFIND, the Depth 1 listing, a GET for the one card it lacked, the
 bootstrap PROPFIND, and a sync-collection report with the fresh token.
 The listing and bootstrap match steps 06 and 09 byte for byte, and the
diff --git a/test/pro_tacts/exchange_fixtures.rb b/test/pro_tacts/exchange_fixtures.rb
index cec37a4..8f743af 100644
--- a/test/pro_tacts/exchange_fixtures.rb
+++ b/test/pro_tacts/exchange_fixtures.rb
@@ -34,6 +34,13 @@ class ExchangeFixtures < Data.define(:directory)
   # a login back.
   REPLAY_LOGIN = "replay@example.com"
 
+  # The stamp a replayed book's first change carries (FixtureData.install
+  # pins it), so the database id its sync tokens embed — the digest of
+  # that first entry, Store#database_id — answers the same recorded as
+  # replayed. A real seed moment differs every run, and the recorded
+  # responses would drift with it.
+  GENESIS = "2000-01-01T00:00:00.000Z" #: String
+
   # Every recording, for the callers that run all of them.
   def self.all
     [MACOS, IOS]
diff --git a/test/pro_tacts/test_exchanges.rb b/test/pro_tacts/test_exchanges.rb
index 05eed77..dd194bf 100644
--- a/test/pro_tacts/test_exchanges.rb
+++ b/test/pro_tacts/test_exchanges.rb
@@ -42,7 +42,7 @@ class ExchangesTest < Minitest::Test
     # one the rest of the suite reads, built fresh so a replay never depends
     # on what a previous run left behind.
     directory = Pathname.new(__dir__).parent.parent / "tmp" / recording.directory.basename
-    store = FixtureData.install(directory)
+    store = FixtureData.install(directory, genesis: ExchangeFixtures::GENESIS)
     original = ProTacts::Web.store
     ProTacts::Web.store = store
 
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 603ec83..ee8cc50 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1,6 +1,7 @@
 require_relative "../test_helper"
 require_relative "../photo_card"
 
+require "digest"
 require "pathname"
 require "tmpdir"
 
@@ -242,6 +243,57 @@ class StoreTest < Minitest::Test
     end
   end
 
+  ## The database a sync token names
+
+  def test_the_databases_id_is_the_digest_of_its_first_change
+    with_store({"aiden" => AIDEN}) do |store|
+      stamp = database(store)[:changes].where(sequence: 1).sole.fetch(:created_at)
+
+      assert_equal Digest::SHA256.hexdigest(stamp)[0, 16], store.database_id
+    end
+  end
+
+  def test_the_databases_id_survives_reopening
+    Dir.mktmpdir do |dir|
+      path = Pathname.new(dir) / "contacts.db"
+      ids = Array.new(2) {
+        ProTacts::Store.connect(path) { |store| store.database_id }
+      }
+
+      assert_equal ids.first, ids.last
+    end
+  end
+
+  # The digest's entropy is the first write's moment, so two databases
+  # seeded alike differ by their stamps — a reseed and a restore, both
+  # moments apart. Two stores in one test can land inside the same
+  # millisecond, so this seeds a different first card: the digest then
+  # answers the difference whatever the clock did.
+  def test_two_databases_answer_different_ids
+    ids = [AIDEN, ZED].map { |card|
+      Dir.mktmpdir do |dir|
+        ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
+          store.put("first", vcard(card))
+          store.database_id
+        end
+      end
+    }
+
+    refute_equal ids.first, ids.last
+  end
+
+  # The empty book's edge: an empty log digests nothing, so its token
+  # 410s once after the first card lands — a resync of nothing.
+  def test_an_empty_book_answers_an_id_until_its_first_card_lands
+    with_store do |store|
+      empty = store.database_id
+      assert_match(/\A\h{16}\z/, empty)
+
+      store.put("aiden", vcard(AIDEN))
+      refute_equal empty, store.database_id
+    end
+  end
+
   ## Timestamps
 
   def test_a_stored_card_is_stamped
diff --git a/test/pro_tacts/test_sync_token.rb b/test/pro_tacts/test_sync_token.rb
index 2cee828..6ffca1d 100644
--- a/test/pro_tacts/test_sync_token.rb
+++ b/test/pro_tacts/test_sync_token.rb
@@ -4,32 +4,41 @@ require "pro_tacts/sync_token"
 
 class SyncTokenTest < Minitest::Test
   BOOK = ProTacts::SyncToken.book("alpha@example.com")
+  DATABASE = "0f1e2d3c4b5a6978" #: String
 
   def test_a_minted_token_reads_back_as_its_sequence
-    assert_equal 22, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", BOOK), BOOK)
+    assert_equal 22, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", BOOK, DATABASE), BOOK, DATABASE)
   end
 
   # Zero is what a book nothing has been written to carries, and it has
   # to read back as a sequence rather than as no token at all.
   def test_the_zero_sequence_reads_back
-    assert_equal 0, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("0", BOOK), BOOK)
+    assert_equal 0, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("0", BOOK, DATABASE), BOOK, DATABASE)
   end
 
   def test_another_books_token_reads_as_nothing
     other = ProTacts::SyncToken.book("zed@example.com")
 
-    assert_nil ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", other), BOOK)
+    assert_nil ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", other, DATABASE), BOOK, DATABASE)
   end
 
-  # The shape macOS was recorded sending before books existed
-  # (test/fixtures/macos-exchange/08-report-sync-collection).
-  def test_a_token_from_before_books_reads_as_nothing
-    assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/1", BOOK)
+  def test_another_databases_token_reads_as_nothing
+    other = "1111111111111111"
+
+    assert_nil ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", BOOK, other), BOOK, DATABASE)
+  end
+
+  # The shapes macOS was recorded sending before tokens named their
+  # database: no book at all (fixture 08), and a book but no database
+  # (fixture 12's request, the spelling this change retires).
+  def test_a_token_from_before_databases_reads_as_nothing
+    assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/1", BOOK, DATABASE)
+    assert_nil ProTacts::SyncToken.read("http://pro-tacts/sync/22/#{BOOK}", BOOK, DATABASE)
   end
 
   def test_anything_that_is_not_a_token_reads_as_nothing
-    ["", "1", "http://pro-tacts/sync//#{BOOK}", "http://pro-tacts/sync/22/#{BOOK}/"].each do |text|
-      assert_nil ProTacts::SyncToken.read(text, BOOK), text
+    ["", "1", "http://pro-tacts/sync//#{BOOK}/#{DATABASE}", "http://pro-tacts/sync/22/#{BOOK}/#{DATABASE}/"].each do |text|
+      assert_nil ProTacts::SyncToken.read(text, BOOK, DATABASE), text
     end
   end
 
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index ee16760..4c1a84f 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -792,8 +792,9 @@ class WebTest < Minitest::Test
   end
 
   # A sync token as the route issues one to a user (Web#sync_token).
-  def issued_token(sequence, login: "test@example.com")
-    "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(login)[0, 16]}"
+  # `database` spells a token another database minted, the wyn case.
+  def issued_token(store, sequence, login: "test@example.com", database: nil)
+    "http://pro-tacts/sync/#{sequence}/#{Digest::SHA256.hexdigest(login)[0, 16]}/#{database || store.database_id}"
   end
 
   def etag_only_propfind
@@ -891,7 +892,7 @@ class WebTest < Minitest::Test
   # only, address-data never.
   def test_sync_collection_reports_the_delta_since_the_token
     with_contacts({"aiden" => "Aiden"}) do |store|
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(0))
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 0))
 
       assert_equal 207, last_response.status
       assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
@@ -915,20 +916,20 @@ class WebTest < Minitest::Test
 
   # The initial sync, RFC 6578 section 3.4.
   def test_sync_collection_with_no_token_lists_every_member
-    with_contacts({"aiden" => "Aiden", "znorth" => "Znorth"}) do
+    with_contacts({"aiden" => "Aiden", "znorth" => "Znorth"}) do |store|
       request "/dav/addressbook/", method: "REPORT", input: sync_collection("")
 
       assert_equal 207, last_response.status
       assert_includes last_response.body, "/dav/addressbook/aiden.vcf"
       assert_includes last_response.body, "/dav/addressbook/znorth.vcf"
-      assert_includes last_response.body, "<d:sync-token>#{issued_token(2)}</d:sync-token>"
+      assert_includes last_response.body, "<d:sync-token>#{issued_token(store, 2)}</d:sync-token>"
     end
   end
 
   # RFC 6578 section 3.2 spells a removal href and 404, no propstat.
   def test_sync_collection_reports_a_removal_as_404
     with_contacts({"aiden" => "Aiden", "znorth" => "Znorth"}) do |store|
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(2))
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 2))
       token = last_response.body[%r{<d:sync-token>(.+)</d:sync-token>}, 1]
       store.delete("znorth")
 
@@ -946,8 +947,8 @@ class WebTest < Minitest::Test
   # present: the DAV:valid-sync-token precondition of RFC 6578
   # section 3.2, marshalled per RFC 4918 section 16.
   def test_sync_collection_refuses_a_token_it_never_issued
-    with_contacts({"aiden" => "Aiden"}) do
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(9))
+    with_contacts({"aiden" => "Aiden"}) do |store|
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 9))
 
       assert_equal 410, last_response.status
       assert_includes last_response.body, "<d:valid-sync-token/>"
@@ -962,14 +963,33 @@ class WebTest < Minitest::Test
   # changed resyncs rather than taking a delta against another book —
   # and a token from before tokens named one is refused the same way.
   def test_sync_collection_refuses_a_token_from_another_book
-    with_contacts({"aiden" => "Aiden"}) do
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1, login: "zoë@example.com"))
+    with_contacts({"aiden" => "Aiden"}) do |store|
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 1, login: "zoë@example.com"))
       assert_equal 410, last_response.status
 
       request "/dav/addressbook/", method: "REPORT", input: sync_collection("http://pro-tacts/sync/1")
       assert_equal 410, last_response.status
 
-      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(1))
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 1))
+      assert_equal 207, last_response.status
+    end
+  end
+
+  # A token names the database that minted it, so a device holding one
+  # from before a reseed or a restore — the same sequences over a
+  # different history — resyncs rather than taking a delta against a
+  # history it never saw
+  # (docs/plans/2026-09-24-sync-tokens-name-their-database.md).
+  def test_sync_collection_refuses_a_token_from_another_database
+    with_contacts({"aiden" => "Aiden"}) do |store|
+      request "/dav/addressbook/", method: "REPORT",
+        input: sync_collection(issued_token(store, 0, database: "1111111111111111"))
+
+      assert_equal 410, last_response.status
+      assert_includes last_response.body, "<d:valid-sync-token/>"
+
+      request "/dav/addressbook/", method: "REPORT", input: sync_collection(issued_token(store, 0))
+
       assert_equal 207, last_response.status
     end
   end