lib: give the names and types some comments were standing in for
`own` in the store meant the card as it stood before a write, which
is not what `Contact#own` means and not what `#save_edit` calls the
same thing; `#composed` was `#contact` without the nil; the ADR
components were spelled out in three files; the sync token was built
in one place and re-read by a regex in another; and the change log's
four actions were bare strings whose only list was a CHECK
constraint.

- `own` is `stored_before`, and the composed contact beside it is
  `replaced`.
- `Store#composed` is `Store#contact!`.
- `Store#rewrite` is `Store#save_edit`, which frees "rewrite" to mean
  what it means everywhere else in the file.
- `Contact::ADDRESS_COMPONENTS` is the one list; CardForm and the
  importer read it.
- `ProTacts::SyncToken` mints and reads the token, and `Web` asks it.
- `Store::Action` names the four the schema admits, and `#record`
  takes its columns by name.

No behavior moves: the fixtures and the replays are the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WWFQaLD58uLFedh3Dvfb2C
change
commit b1d0704f3bd33737b0e1408e4a103b3b099c9849
author Claude <noreply@anthropic.com>
date
parent 0764e64e
diff --git a/AGENTS.md b/AGENTS.md
index 1e8e329..09eff06 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -78,6 +78,8 @@ lib/pro_tacts/
 ├── birthday.rb     # A partial date, the one fact no card can hold
 ├── birthday_line.rb # What a submitted BDAY line is, for Store#put
 ├── card_diff.rb    # The lines a write added and removed
+├── sync_token.rb   # The token a syncing client carries: minted
+│                   # at a ctag for a book, and read back
 ├── vcard.rb        # vCard 3.0 escaping, and what a card is
 ├── vcard/parser.rb # One card's bytes into lines
 ├── proxy_auth.rb   # The login, off the header the proxy writes
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 1d95117..398923d 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -1,4 +1,5 @@
 require "pro_tacts/birthday"
+require "pro_tacts/contact"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
 
@@ -13,21 +14,6 @@ module ProTacts
     #
     # Not a view, unlike its neighbours, so the Steepfile checks it.
     module CardForm
-      # ADR's editable components: field name to position in the value
-      # (RFC 2426 section 3.2.1, minus the leading po box at position 0
-      # — no screen shows one, and the save preserves its bytes rather
-      # than letting a form field near them). In the value's own order,
-      # which is the order a rebuilt line's components join in.
-      ADDRESS_COMPONENTS = {
-        "extended" => 1,
-        "street" => 2,
-        "locality" => 3,
-        "region" => 4,
-        "postal_code" => 5,
-        "country" => 6,
-      }.freeze #: Hash[String, Integer]
-      private_constant :ADDRESS_COMPONENTS
-
       # A created contact's card: the envelope RFC 2426 section 4
       # requires — BEGIN, VERSION, and the N and FN that section makes
       # mandatory — plus the UID this server's id model is (see
@@ -189,7 +175,7 @@ module ProTacts
 
       #: (Contact::Address address, untyped submitted) -> bool
       def self.address_unchanged?(address, submitted)
-        ADDRESS_COMPONENTS.keys.all? { |name|
+        Contact::ADDRESS_COMPONENTS.all? { |name|
           submitted[name].to_s.strip == address.public_send(name).to_s
         }
       end
@@ -216,7 +202,8 @@ module ProTacts
           components = VCard.split_raw_components(property.value)
         end
         components << "" while components.length < 7
-        ADDRESS_COMPONENTS.each do |name, position|
+        Contact::ADDRESS_COMPONENTS.each_with_index do |name, index|
+          position = index + 1
           value = submitted[name].to_s.strip
           current = address && address.public_send(name).to_s
           components[position] = value == current ? components.fetch(position) : VCard.escape(value)
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index b0d79f7..4a0839c 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -55,13 +55,12 @@ module ProTacts
       private_constant :FORM
 
       # The address row's component fields, stacked in the value
-      # column in the order an address form reads — RFC 2426 section
-      # 3.2.1's own order minus the po box, which no screen shows and
-      # the save splices around. Field name to placeholder: a
-      # component is not an attribute and earns no type column of its
-      # own, so the placeholder is the label and the conventional
-      # order carries it. Public because a group's address rows are
-      # these same rows (Admin::GroupsEdit).
+      # column in the order an address form reads, which is not the
+      # order the value spells them in (Contact::ADDRESS_COMPONENTS).
+      # Field name to placeholder: a component is not an attribute and
+      # earns no type column of its own, so the placeholder is the
+      # label and the conventional order carries it. Public because a
+      # group's address rows are these same rows (Admin::GroupsEdit).
       ADDRESS_FIELDS = [
         ["street", "street"],
         ["extended", "street 2"],
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 026eaad..9e6d283 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -93,6 +93,14 @@ module ProTacts
       :line,
     )
 
+    # An ADR's editable components, in the order the value spells them
+    # (RFC 2426 section 3.2.1). The post office box heads that value
+    # and is not among them — no screen shows one, and a save
+    # preserves its bytes rather than letting a form field near them —
+    # so a component's position in the value is one past its index
+    # here.
+    ADDRESS_COMPONENTS = %w[extended street locality region postal_code country].freeze #: Array[String]
+
     #: (VCard vcard) -> String
     def self.etag_for(vcard)
       %("#{Digest::SHA256.hexdigest(vcard.to_s)}")
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index 45409ce..b4fac74 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -28,11 +28,6 @@ module ProTacts
       # so the two are read together and edited in one place.
       SOURCE_KEYS = %w[identifier vcard note contact].freeze #: Array[String]
 
-      # An address's parts, as the editor's own add row names them
-      # (Admin::CardForm's ADDRESS_COMPONENTS, which is private to it).
-      # No post office box: no screen shows one either.
-      ADDRESS_KEYS = %w[extended street locality region postal_code country].freeze #: Array[String]
-
       # A phone's parts: a number, and the label the source card's
       # X-ABLabel gave the row, left out rather than written empty
       # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
@@ -69,7 +64,7 @@ module ProTacts
         invalid.("phones must be a list, each with a quoted number and an optional label") unless phones.is_a?(Array) && phones.all? { phone?(it) }
         invalid.("emails must be a list of addresses") unless texts?(emails)
         addresses.is_a?(Array) && addresses.all? { address?(it) } or
-          invalid.("addresses must be a list, each with any of #{ADDRESS_KEYS.join(", ")}")
+          invalid.("addresses must be a list, each with any of #{Contact::ADDRESS_COMPONENTS.join(", ")}")
         invalid.("groups must be a list of names") unless texts?(groups) && groups.none? { it.strip.empty? }
         invalid.("note must be text, or blank") unless note.nil? || note.is_a?(String)
         invalid.("photo must be true or false") unless [true, false].include?(photo)
@@ -89,7 +84,7 @@ module ProTacts
 
       #: (untyped value) -> bool
       def self.address?(value)
-        value.is_a?(Hash) && (value.keys - ADDRESS_KEYS).empty? &&
+        value.is_a?(Hash) && (value.keys - Contact::ADDRESS_COMPONENTS).empty? &&
           value.values.all?(String) && value.values.any? { !it.strip.empty? }
       end
 
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index a7b2485..23c5906 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -3,6 +3,7 @@ require "open3"
 require "pathname"
 
 require "pro_tacts/change_id"
+require "pro_tacts/contact"
 require "pro_tacts/import/card"
 require "pro_tacts/import/plan"
 require "pro_tacts/vcard/parser"
@@ -206,7 +207,7 @@ module ProTacts
         # The post office box leads the value and has no field; a card
         # carrying one does not get this far (see ADR's value check).
         _po_box, *parts = property.components
-        Card::ADDRESS_KEYS.zip(parts).to_h { |key, value|
+        Contact::ADDRESS_COMPONENTS.zip(parts).to_h { |key, value|
           [key, value.to_s] #: [String, String]
         }.reject { |_key, value| value.empty? }
       end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 1aa9511..0c911d8 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -45,6 +45,17 @@ module ProTacts
     # @rbs skip
     Change = Data.define(:sequence, :card_id, :action, :etag, :diff, :created_at)
 
+    # What an entry says happened. The four the schema admits and no
+    # fifth — the CHECK constraint's own list
+    # (db/migrations/006_change_diffs.rb), which is what refuses one
+    # this does not name.
+    module Action
+      PUT = "put" #: String
+      EDIT = "edit" #: String
+      DELETE = "delete" #: String
+      GROUP = "group" #: String
+    end
+
     # A contact paired with when its card last changed. Contact itself
     # carries no timestamp — it is derived from the card alone, see its
     # own comment — so a surface that sorts or displays recency (the
@@ -438,21 +449,18 @@ module ProTacts
     # `client` marks a client's write: a card it creates joins `sync:*`,
     # created on first use
     # (docs/plans/2026-09-15-client-creates-join-everyone.md). A
-    # rewrite of a card that exists joins nothing.
+    # put over a card that exists joins nothing.
     #: (String id, VCard vcard, ?client: bool) -> Contact
     def put(id, vcard, client: false)
       existing = birthday_of(id)
-      # The card as it stands before this write, read once for the
-      # three things that want it: the birthday split, which reports
-      # what a rewrite drops; the subtraction after it, which accounts
-      # for the member's own lines before attributing any to a group;
-      # and the card the change log records this write as replacing.
-      own = stored_card(id)
-      birthday, stored = split_birthday(vcard, existing, own)
+      # Read once for the three that want it: the birthday split, the
+      # subtraction after it, and the card this write replaces.
+      stored_before = stored_card(id)
+      birthday, stored = split_birthday(vcard, existing, stored_before)
 
       # The other half of the split, the same shape as the birthday's:
       # what the groups lend comes back out of the submission before it
-      # is stored, so a rewrite cannot materialize a group's lines into
+      # is stored, so a write cannot materialize a group's lines into
       # the member's own card.
       #
       # The Contact this returns is the composed one — those lines put
@@ -461,17 +469,17 @@ module ProTacts
       # it downloads.
       lent = lent_of(id)
       inherited = inheritance(lent)
-      # The card this write replaces, composed from the parts already
-      # read here rather than through #contact, which would read all
-      # three again. Membership cannot move during a put, so the
-      # inheritance either side of it is the one read above.
-      before = own && Contact.new(id:, stored: own, birthday: existing, inherited:)
-      stored, edits = subtract_inherited(stored, lent, own)
+      # Composed from the parts already read here rather than through
+      # #contact, which would read all three again. Membership cannot
+      # move during a put, so the inheritance either side of it is the
+      # one read above.
+      replaced = stored_before && Contact.new(id:, stored: stored_before, birthday: existing, inherited:)
+      stored, edits = subtract_inherited(stored, lent, stored_before)
       # Built before the transaction as well as inside it, because its
       # constructor is where an id that could not be served is refused
       # and a refusal from inside a transaction reaches the caller
       # wearing Sequel's own error class. Composition is lazy, so the
-      # one that gets replaced below cost nothing to make.
+      # one the fan-out throws away below cost nothing to make.
       contact = Contact.new(id:, stored:, birthday:, inherited:)
       @database.transaction do
         # The groups move first, so that everything composed below is
@@ -488,19 +496,23 @@ module ProTacts
         # before the log entry, which records the card composed with
         # whatever the group lends. The put's entry is the card's arrival
         # in the book, so the join writes none of its own.
-        if client && own.nil?
+        if client && stored_before.nil?
           group_members.insert(group_id: everyone_group_id, card_id: contact.id)
           contact = Contact.new(id:, stored:, birthday:, inherited: inherited_of(contact.id))
         end
         write_birthday(contact.id, birthday)
-        record(contact.id, "put", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
+        record(
+          contact.id,
+          action: Action::PUT,
+          etag: contact.etag,
+          diff: CardDiff.between(replaced&.vcard, contact.vcard),
+        )
         reindex(contact.id, stored)
         contact
       end
     end
 
-    # The store's own editor changed a contact, against #put's "a
-    # client submitted a card" — why the two paths cannot be one is
+    # Why this and #put cannot be one path is
     # docs/plans/2026-09-05-web-card-editor.md, "Two write paths
     # through the store". The card input is the stored one by
     # definition, and `birthday:` is the model's whole new state, an
@@ -510,13 +522,18 @@ module ProTacts
     # make this save, and re-reading its bytes here would walk them
     # again to reach what the caller already had.
     #: (String id, VCard vcard, birthday: Birthday?) -> Contact
-    def rewrite(id, vcard, birthday:)
+    def save_edit(id, vcard, birthday:)
       before = contact(id)
       contact = Contact.new(id:, stored: vcard, birthday:, inherited: inherited_of(id))
       @database.transaction do
         upsert_card(contact.id, vcard)
         write_birthday(contact.id, birthday)
-        record(contact.id, "edit", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
+        record(
+          contact.id,
+          action: Action::EDIT,
+          etag: contact.etag,
+          diff: CardDiff.between(before&.vcard, contact.vcard),
+        )
         reindex(contact.id, vcard)
       end
       contact
@@ -531,7 +548,7 @@ module ProTacts
       @database.transaction do
         before = contact(id)
         deleted = cards.where(id:).delete.positive?
-        record(id, "delete", nil, CardDiff.between(before&.vcard, nil)) if deleted
+        record(id, action: Action::DELETE, etag: nil, diff: CardDiff.between(before&.vcard, nil)) if deleted
         deleted
       end
     end
@@ -585,8 +602,13 @@ module ProTacts
             # parameters follow their properties away on the cascade.
             card_properties.where(card_id: id).delete
             after = contact_from(cards.where(id: new_id).sole, birthday_of(new_id), inherited_of(new_id))
-            record(id, "delete", nil, CardDiff.between(before.vcard, nil))
-            record(new_id, "put", after.etag, CardDiff.between(before.vcard, after.vcard))
+            record(id, action: Action::DELETE, etag: nil, diff: CardDiff.between(before.vcard, nil))
+            record(
+              new_id,
+              action: Action::PUT,
+              etag: after.etag,
+              diff: CardDiff.between(before.vcard, after.vcard),
+            )
             reindex(new_id, stored)
           end
           return new_id
@@ -864,8 +886,8 @@ module ProTacts
     # off a `before` and an `after`: only the caller knows which two
     # cards its write was between, and a delete's `after` is nothing at
     # all.
-    #: (String card_id, String action, String? etag, CardDiff diff) -> void
-    def record(card_id, action, etag, diff)
+    #: (String card_id, action: String, etag: String?, diff: CardDiff) -> void
+    def record(card_id, action:, etag:, diff:)
       change_log.insert(card_id:, action:, etag:, diff: diff.to_json)
     end
 
@@ -888,15 +910,15 @@ module ProTacts
     def fan_out(cards, except: nil, moved: [])
       ids = cards - [except].compact
       before = ids.to_h {
-        [it, composed(it)] #: [String, Contact]
+        [it, contact!(it)] #: [String, Contact]
       }
       yield
       ids.each do |id|
         was = before.fetch(id)
-        now = composed(id)
+        now = contact!(id)
         next if was.vcard.to_s == now.vcard.to_s && !moved.include?(id)
 
-        record(id, "group", now.etag, CardDiff.between(was.vcard, now.vcard))
+        record(id, action: Action::GROUP, etag: now.etag, diff: CardDiff.between(was.vcard, now.vcard))
       end
     end
 
@@ -976,12 +998,11 @@ module ProTacts
         .map { it.fetch(:card_id).to_s }
     end
 
-    # A member's card as it composes right now. #contact's read without
-    # the rescue it makes: a group_members row names a card by foreign
-    # key, so no row here is the corruption `sole` exists to raise on
-    # rather than the ordinary miss an href off the wire is.
+    # A group_members row names a card by foreign key, so a missing
+    # row here is the corruption `sole` exists to raise on rather than
+    # the ordinary miss an href off the wire is.
     #: (String id) -> Contact
-    def composed(id)
+    def contact!(id)
       contact_from(cards.where(id:).sole, birthday_of(id), inherited_of(id))
     end
 
@@ -1032,16 +1053,16 @@ module ProTacts
     #
     # No BDAY is a client that was sent the birthday removing it, or
     # one that was never sent it and so cannot have. A BDAY line left
-    # in the stored card goes with the rewrite, which is worth saying.
+    # in the stored card goes with the write, which is worth saying.
     #
     # case/when rather than case/in, BirthdayLine's own rule: Steep
     # does not check the bodies of case/in branches, and these are the
     # ones worth checking — which is what the last arm is for.
-    #: (VCard vcard, Birthday? existing, VCard? own) -> [Birthday?, VCard]
-    def split_birthday(vcard, existing, own)
+    #: (VCard vcard, Birthday? existing, VCard? stored_before) -> [Birthday?, VCard]
+    def split_birthday(vcard, existing, stored_before)
       bdays, rest = vcard.extract("BDAY")
       if bdays.empty?
-        report_lost_bday_lines(own)
+        report_lost_bday_lines(stored_before)
         return [existing && !existing.served? ? existing : nil, vcard]
       end
 
@@ -1114,11 +1135,11 @@ module ProTacts
     # this leaves keep their positions, so a submission that was the
     # served card round-trips to the bytes it was composed from and the
     # PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
-    #: (VCard vcard, Array[Lent] lent, VCard? own) -> [VCard, Array[GroupEdit]]
-    def subtract_inherited(vcard, lent, own)
+    #: (VCard vcard, Array[Lent] lent, VCard? stored_before) -> [VCard, Array[GroupEdit]]
+    def subtract_inherited(vcard, lent, stored_before)
       return [vcard, []] if lent.empty?
 
-      unaccounted = unaccounted_lines(vcard, own)
+      unaccounted = unaccounted_lines(vcard, stored_before)
       taken = [] #: Array[VCard::Parser::Line]
 
       moved = lent.reject { |row|
@@ -1289,16 +1310,16 @@ module ProTacts
       property.types.sort
     end
 
-    # The submission's lines that the member's own stored card does not
-    # already explain — one struck per stored line of the same bytes,
+    # The submission's lines the card as it stood before this write
+    # does not already explain — one struck per stored line of the same bytes,
     # so a card that stores one of something and submits two leaves one
     # over. What is left is what the groups lent plus whatever the
     # client wrote beside it, which is the pool a lent line is
     # attributed from. Everything for a card being created, which has
     # no stored lines to explain anything.
-    #: (VCard vcard, VCard? own) -> Array[VCard::Parser::Line]
-    def unaccounted_lines(vcard, own)
-      stored = own ? own.lines.map { it.verbatim.chomp } : [] #: Array[String]
+    #: (VCard vcard, VCard? stored_before) -> Array[VCard::Parser::Line]
+    def unaccounted_lines(vcard, stored_before)
+      stored = stored_before ? stored_before.lines.map { it.verbatim.chomp } : [] #: Array[String]
       vcard.lines.reject { |line|
         index = stored.index(line.verbatim.chomp)
         stored.delete_at(index) if index
@@ -1350,17 +1371,17 @@ module ProTacts
       )
     end
 
-    # The loss report, the rewrite's half of the arrival one: a BDAY
-    # line still in the stored card is one the model did not take, and a
-    # rewrite without it is about to drop it with nobody told.
-    #: (VCard? own) -> void
-    def report_lost_bday_lines(own)
-      return if own.nil?
+    # The loss report, the write's half of the arrival one: a BDAY line
+    # still in the stored card is one the model did not take, and a
+    # write without it is about to drop it with nobody told.
+    #: (VCard? stored_before) -> void
+    def report_lost_bday_lines(stored_before)
+      return if stored_before.nil?
 
-      lines, = own.extract("BDAY")
+      lines, = stored_before.extract("BDAY")
       return if lines.empty?
 
-      Sentry.capture_message("a rewrite dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
+      Sentry.capture_message("a write dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
     end
 
     # The birthday travels beside the card rather than inside it, and
diff --git a/lib/pro_tacts/sync_token.rb b/lib/pro_tacts/sync_token.rb
new file mode 100644
index 0000000..d321f94
--- /dev/null
+++ b/lib/pro_tacts/sync_token.rb
@@ -0,0 +1,36 @@
+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").
+  # Built on the ctag so that a client polling either one sees changes
+  # at the same points.
+  module SyncToken
+    PREFIX = "http://pro-tacts/sync/" #: String
+
+    # The book half: the first 16 hex digits of the SHA-256 of the
+    # requester's login.
+    #: (String login) -> String
+    def self.book(login)
+      Digest::SHA256.hexdigest(login)[0, 16].to_s
+    end
+
+    #: (String ctag, String book) -> String
+    def self.mint(ctag, book)
+      "#{PREFIX}#{ctag}/#{book}"
+    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
+    end
+  end
+end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index c3e1a13..36ede4b 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -182,7 +182,7 @@ module ProTacts
         return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
       end
 
-      store.rewrite(id, Admin::CardForm.contact_card(contact, first, middle, last, r.params), birthday:)
+      store.save_edit(id, Admin::CardForm.contact_card(contact, first, middle, last, r.params), birthday:)
       r.redirect "/contacts/#{id}", 303
     end
 
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index ff18c45..8af8cb8 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -1,7 +1,7 @@
-require "digest"
 require "nokogiri"
 
 require "pro_tacts/dav_xml"
+require "pro_tacts/sync_token"
 
 module ProTacts
   # The CardDAV half of the router: service discovery and the one
@@ -163,7 +163,7 @@ module ProTacts
             # client back through multiget, so no address-data here.
             #
             # The token carries the change log's sequence and whose book
-            # it was issued for (see #sync_token), and the delta is the
+            # it was issued for (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,9 +190,8 @@ module ProTacts
                   etag_response(d, it)
                 end
               end
-            elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)/#{book_digest}\z}, 1]) &&
-                sequence.to_i <= store.latest_sequence
-              net = store.changes(after: sequence.to_i).map { it.card_id }.uniq
+            elsif (sequence = SyncToken.read(token, book_digest)) && sequence <= store.latest_sequence
+              net = store.changes(after: sequence).map { it.card_id }.uniq
               multistatus("card", sync_token:) do |d|
                 net.each do |id|
                   contact = contacts.find { it.id == id }
@@ -346,22 +345,16 @@ module ProTacts
       @ctag ||= store.ctag
     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, and on the requester's login
-    # so that a token is refused by any other book, the one a rename
-    # leaves a user with included (docs/plans/2026-09-12-per-user-books.md,
-    # "The wire").
+    # 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.
     #: () -> String
     def sync_token
-      "http://pro-tacts/sync/#{ctag}/#{book_digest}"
+      SyncToken.mint(ctag, book_digest)
     end
 
-    # The name half of a sync token: the first 16 hex digits of the
-    # SHA-256 of the requester's login.
     #: () -> String
     def book_digest
-      Digest::SHA256.hexdigest(@login)[0, 16].to_s
+      SyncToken.book(@login)
     end
 
     # The whole of the PUT route: a private method because a Roda route
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 98937c9..3e80609 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -670,8 +670,8 @@ class AdminContactsPagesTest < Minitest::Test
   # the action for, rendered here.
   def test_the_change_log_leads_with_the_latest_entry
     with_contacts({"ada" => ADA}) do |store|
-      store.rewrite("ada", ProTacts::VCard.new(ADA.sub("Ada Lovelace", "Ada L.")),
-                    birthday: store.contact("ada").birthday)
+      store.save_edit("ada", ProTacts::VCard.new(ADA.sub("Ada Lovelace", "Ada L.")),
+                      birthday: store.contact("ada").birthday)
 
       get "/contacts/ada"
 
@@ -700,8 +700,8 @@ class AdminContactsPagesTest < Minitest::Test
   # and signed rather than only colored.
   def test_the_change_log_shows_what_each_write_moved
     with_contacts({"ada" => ADA}) do |store|
-      store.rewrite("ada", ProTacts::VCard.new(ADA.sub("FN:Ada Lovelace", "FN:Ada L.")),
-                    birthday: store.contact("ada").birthday)
+      store.save_edit("ada", ProTacts::VCard.new(ADA.sub("FN:Ada Lovelace", "FN:Ada L.")),
+                      birthday: store.contact("ada").birthday)
 
       get "/contacts/ada"
 
@@ -1211,7 +1211,7 @@ class AdminContactsPagesTest < Minitest::Test
   # The save is addressed operations, and this is the whole design:
   # the four properties the form names are replaced, every other line
   # of the stored card is byte-identical, and the write lands through
-  # Store#rewrite with its change-log entry.
+  # Store#save_edit with its change-log entry.
   def test_saving_the_edit_replaces_only_the_addressed_properties
     with_contacts({"ada" => ADA}) do |store|
       post "/contacts/ada", first: "Ada", last: "King", nickname: "Countess",
@@ -1769,7 +1769,7 @@ class AdminContactsPagesTest < Minitest::Test
   # A birthday edit is a model write: the served card composes the new
   # BDAY, the stored bytes never move, and the change-log entry carries
   # the new composed etag — a client's token sees what a device
-  # downloads (Store#rewrite).
+  # downloads (Store#save_edit).
   def test_saving_a_changed_birthday_moves_the_served_card_only
     with_contacts({"ada" => ADA}) do |store|
       before = store.contact("ada").stored.to_s
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 9187c4b..ab3d55c 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -328,18 +328,18 @@ class StoreTest < Minitest::Test
 
   ## The editor's write
 
-  # rewrite is the store's own editor's path: the bytes land byte for
+  # save_edit is the store's own editor's path: the bytes land byte for
   # byte, the change-log entry carries the composed etag a client
   # would download, and the action names the side that wrote it — put
   # is a client's submission, edit is the admin's save. The birthday
   # passed beside the card is the model's whole new state
   # (docs/plans/2026-09-07-web-birthday-editor.md), so the caller
   # handing back the current one is the card-only save.
-  def test_a_rewrite_stores_the_card_and_logs_the_composed_etag
+  def test_an_edit_stores_the_card_and_logs_the_composed_etag
     with_store({"aiden" => AIDEN_BORN}) do |store|
       edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
 
-      contact = store.rewrite("aiden", vcard(edited), birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+      contact = store.save_edit("aiden", vcard(edited), birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
 
       assert_equal edited, card_row(store, "aiden").fetch(:vcard)
       composed = edited.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
@@ -351,16 +351,16 @@ class StoreTest < Minitest::Test
     end
   end
 
-  # The hazard rewrite exists for: an editor's save carries no BDAY
+  # The hazard save_edit exists for: an editor's save carries no BDAY
   # line by construction, and put reads exactly that as a birthday the
   # client dropped — so running the save through put would delete the
-  # model row and the contact's birthday with it. rewrite writes the
+  # model row and the contact's birthday with it. save_edit writes the
   # birthday it is handed, so the caller passing the current model —
   # what a card-only save does — keeps the row.
-  def test_a_rewrite_writing_the_current_birthday_keeps_it
+  def test_an_edit_writing_the_current_birthday_keeps_it
     with_store({"aiden" => AIDEN_BORN}) do |store|
-      store.rewrite("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")),
-                    birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
+      store.save_edit("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")),
+                      birthday: ProTacts::Birthday.new(year: 1985, month: 4, day: 12))
 
       assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
       assert_includes store.contact("aiden").vcard.to_s, "BDAY:1985-04-12"
@@ -371,11 +371,11 @@ class StoreTest < Minitest::Test
   # it: the composed BDAY lands at the new value, the stored bytes do
   # not move at all, and the change-log entry carries the new composed
   # etag — a client's token has to see what a device would download.
-  def test_a_rewrite_with_a_new_birthday_moves_the_served_card_only
+  def test_an_edit_with_a_new_birthday_moves_the_served_card_only
     with_store({"aiden" => AIDEN_BORN}) do |store|
       stored = AIDEN_BORN.sub("BDAY:1985-04-12\r\n", "")
 
-      contact = store.rewrite("aiden", vcard(stored), birthday: ProTacts::Birthday.new(month: 4, day: 12))
+      contact = store.save_edit("aiden", vcard(stored), birthday: ProTacts::Birthday.new(month: 4, day: 12))
 
       assert_equal stored, card_row(store, "aiden").fetch(:vcard)
       assert_includes contact.vcard.to_s, "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\n"
@@ -386,11 +386,11 @@ class StoreTest < Minitest::Test
 
   # Nil is the row's blank-equals-absent: the model row goes, and the
   # composed card with it.
-  def test_a_rewrite_with_no_birthday_deletes_the_row
+  def test_an_edit_with_no_birthday_deletes_the_row
     with_store({"aiden" => AIDEN_BORN}) do |store|
       stored = AIDEN_BORN.sub("BDAY:1985-04-12\r\n", "")
 
-      contact = store.rewrite("aiden", vcard(stored), birthday: nil)
+      contact = store.save_edit("aiden", vcard(stored), birthday: nil)
 
       assert_nil birthday_row(store, "aiden")
       refute_includes contact.vcard.to_s, "BDAY"
@@ -1196,7 +1196,7 @@ class StoreTest < Minitest::Test
       FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
 
       put = store.put("aiden", vcard(store.contact("aiden").vcard.to_s))
-      edit = store.rewrite("aiden", vcard(AIDEN), birthday: nil)
+      edit = store.save_edit("aiden", vcard(AIDEN), birthday: nil)
       logged = database(store)[:changes].where(card_id: "aiden").order(:sequence).all
 
       assert_equal [put.etag, edit.etag], logged.last(2).map { it[:etag] }
@@ -2054,7 +2054,7 @@ class StoreTest < Minitest::Test
         assert_empty store.changes
 
         store.put("aiden", vcard(AIDEN))
-        store.rewrite("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")), birthday: nil)
+        store.save_edit("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")), birthday: nil)
         change = store.changes.last
         assert_equal %w[aiden edit], [change.card_id, change.action]
         assert_equal 2, change.sequence
diff --git a/test/pro_tacts/test_sync_token.rb b/test/pro_tacts/test_sync_token.rb
new file mode 100644
index 0000000..2cee828
--- /dev/null
+++ b/test/pro_tacts/test_sync_token.rb
@@ -0,0 +1,40 @@
+require_relative "../test_helper"
+
+require "pro_tacts/sync_token"
+
+class SyncTokenTest < Minitest::Test
+  BOOK = ProTacts::SyncToken.book("alpha@example.com")
+
+  def test_a_minted_token_reads_back_as_its_sequence
+    assert_equal 22, ProTacts::SyncToken.read(ProTacts::SyncToken.mint("22", BOOK), BOOK)
+  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)
+  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)
+  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)
+  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
+    end
+  end
+
+  def test_a_book_is_sixteen_hex_digits_of_its_login
+    assert_match(/\A[0-9a-f]{16}\z/, BOOK)
+    refute_equal BOOK, ProTacts::SyncToken.book("zed@example.com")
+  end
+end