store, admin: log the lines each write moved
An etag says a card changed but never says how,
so each write hands the log the lines it moved, from now.
No old row had a diff, and none could be spun,
so the table came down and the sequence starts at one.

Assisted-by: Claude Opus 5 via Claude Code
change smqqpnxmyqlsryuvlqptmrrylznlpuon
commit d2b00bcedd0cbe697505ed484c6d623d74d3816b
author Alpha Chen <alpha@kejadlen.dev>
date
parent okuwnzyk
diff --git a/AGENTS.md b/AGENTS.md
index e77d55e..97ed937 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -169,6 +169,12 @@ not a fixture; edit a `.vcf` to change what the replay serves.
   one — no second path takes an etag on trust. The etag in `changes` is
   the exception and is not the same fact — it is what the card hashed
   to at that write, which nothing can recompute once the card moves on.
+  Its `diff` is the same kind of fact and the same kind of exception
+  (`CardDiff`): the lines that write added and removed, of the card as
+  served, so the etag and the diff describe one download. It records a
+  card's lines and not its bytes — folding is normalized away — so it
+  says what a write did without being able to rebuild what it did it
+  to.
 - Nothing above `vcard/parser.rb` handles a parse error, and nothing
   should start. `Parser.lines` is the only read: a line that will not
   read comes back as a Line with no property. The card is served from
diff --git a/Steepfile b/Steepfile
index ad199e6..b0cbfac 100644
--- a/Steepfile
+++ b/Steepfile
@@ -21,5 +21,5 @@ target :lib do
   ignore "lib/pro_tacts/admin", inline: true
   signature "sig"
 
-  library "date", "digest", "fileutils", "logger", "pathname", "securerandom", "strscan"
+  library "date", "digest", "fileutils", "json", "logger", "pathname", "securerandom", "strscan"
 end
diff --git a/db/migrations/006_change_diffs.rb b/db/migrations/006_change_diffs.rb
new file mode 100644
index 0000000..18cccf6
--- /dev/null
+++ b/db/migrations/006_change_diffs.rb
@@ -0,0 +1,44 @@
+# The change log grows a diff: the lines each write added and removed,
+# as the JSON object ProTacts::CardDiff spells. The etag says a card
+# changed and the diff says how — and like the etag, it is a fact about
+# a card that has since moved on, so nothing can derive it later. The
+# card as served, not the bytes stored, for the etag's own reason: the
+# two describe one write, and a client's download is what both are
+# about.
+#
+# The old rows are dropped rather than copied across, which every
+# migration before this one refused to do. An entry written before this
+# column has no diff and no way to be given one, so carrying it would
+# mean a nullable column whose whole point is that it is never null —
+# and this address book has never run anywhere but a development
+# machine, so the history discarded is a seeded database's.
+#
+# What that costs, if this is ever untrue: the sequence starts at 1
+# again, so a client holding a token from the old log sits past the end
+# of the new one and hears about nothing until the log grows past its
+# number, and Store#ctag — the sequence itself — moves backwards, which
+# a client is entitled to read as no change at all. Both are resolved
+# by removing the account and adding it again. A later column on this
+# table is an ALTER TABLE ADD COLUMN or a rebuild that copies, never
+# this.
+#
+# `text: true` on the string columns because these are STRICT tables,
+# which refuse Sequel's plain String. A local rather than a constant,
+# for the reason 001 records.
+now = "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"
+
+Sequel.migration do
+  change do
+    drop_table(:changes)
+
+    create_table(:changes, strict: true) do
+      primary_key :sequence, type: Integer
+      String :card_id, text: true, null: false
+      String :action, text: true, null: false
+      String :etag, text: true
+      String :diff, text: true, null: false
+      String :created_at, text: true, null: false, default: Sequel.lit(now)
+      constraint(:action_is_known, action: %w[put delete edit group])
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index e39cf13..6a0b1aa 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -214,12 +214,12 @@ module ProTacts
 
       # One row per entry, on the record's own grid: the action in the
       # type column, because it is the kind of thing the row is, and
-      # the moment and the etag in the value column. Both of those are
-      # the value — the etag is what the card hashed to at that write
-      # and the one hash in the schema that is stored rather than
-      # derived (AGENTS.md), so it is shown whole. A delete's etag is
-      # null, and the row is its moment alone: a tombstone hashes
-      # nothing.
+      # the moment, the etag, and the lines the write moved in the
+      # value column. All three are the value — the etag is what the
+      # card hashed to at that write and the one hash in the schema
+      # that is stored rather than derived (AGENTS.md), so it is shown
+      # whole. A delete's etag is null, and the row is its moment and
+      # the card it carried away: a tombstone hashes nothing.
       #: () -> void
       def change_rows
         @changes.each do |change|
@@ -227,10 +227,45 @@ module ProTacts
           dd(class: "type-body-sm") do
             div { Format.stamp(change.created_at) }
             div(class: "type-mono") { change.etag } if change.etag
+            diff_lines(change.diff)
           end
         end
       end
 
+      # Removed lines and then added ones, each marked the way a diff
+      # marks them — the sign, not the color, is what says which, so a
+      # row reads the same to someone who cannot tell the two hues
+      # apart. Order within each is the card's own (CardDiff), and a
+      # write that moved no line at all renders nothing rather than an
+      # empty block: a rewrite storing what was already there is a real
+      # entry with nothing to show.
+      #: (CardDiff diff) -> void
+      def diff_lines(diff)
+        return if diff.empty?
+
+        div(class: "diff type-mono") do
+          # Named rather than `it`: Phlex yields the component to an
+          # element's block, so an inner `it` is this view, not the
+          # line.
+          diff.removed.each { |line| div(class: "diff-removed") { "-#{elide_text(line)}" } }
+          diff.added.each { |line| div(class: "diff-added") { "+#{elide_text(line)}" } }
+        end
+      end
+
+      # A diff's line elided the way the raw card's are. It arrives as
+      # text rather than as a card's line, so it is read back into one
+      # to reach #elide's rebuild — worth the parse: the first entry of
+      # a card with a picture is the whole card, and a base64 payload
+      # is exactly the wall ELIDE_ABOVE exists for. A line the parser
+      # cannot read still elides, by the byte surgery #elide falls back
+      # to; a line the terminator was stripped from keeps none, so the
+      # rebuilt one sheds the CRLF #elide gives it.
+      #: (String text) -> String
+      def elide_text(text)
+        line = VCard::Parser.lines(text).first
+        line ? elide(line).chomp : text
+      end
+
       # The stored card for display: byte for byte, except a value long
       # enough to be a wall. The count says exactly what is there; what
       # it stands in for is not readable text.
diff --git a/lib/pro_tacts/card_diff.rb b/lib/pro_tacts/card_diff.rb
new file mode 100644
index 0000000..524f496
--- /dev/null
+++ b/lib/pro_tacts/card_diff.rb
@@ -0,0 +1,93 @@
+require "json"
+
+require "pro_tacts/vcard"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+  # What one write did to a card, as the lines it added and the lines
+  # it removed — the change log's second fact that nothing can
+  # recompute (see Store), since the card this one replaced is gone the
+  # moment the write lands.
+  #
+  # A multiset difference over the card's lines, not an edit script:
+  # macOS Contacts reorders properties on every card it touches
+  # (docs/macos-contacts.md), so a positional diff would report a
+  # shuffle as a change to every line below the first move. Lines that
+  # both cards carry cancel, however far apart they sit, and what is
+  # left is the write. A card carrying the same line twice is two
+  # lines, which is why this counts rather than sets: dropping one copy
+  # is a removal, and a set difference would call it nothing.
+  #
+  # The unit is the logical line unfolded and shorn of its terminator,
+  # which is the line as the grammar reads it (RFC 2426 section 2.6
+  # folding). So a fold moved or a card rewritten CRLF for LF is no
+  # change here, and the diff is a record of a card's lines rather than
+  # of its bytes — it cannot rebuild the card it came from, and is not
+  # meant to.
+  #
+  # The signature lives in sig/pro_tacts/card_diff.rbs, this being a
+  # Data class.
+  # @rbs skip
+  CardDiff = Data.define(:added, :removed)
+
+  # Reopened rather than defined in the block above, for the reason
+  # VCard::Parser::Property is.
+  class CardDiff
+    # The write from the card before it to the card after it, either of
+    # them nil: a create adds every line of a card that was not there,
+    # and a delete removes every line of one that will not be.
+    #: (VCard? before, VCard? after) -> CardDiff
+    def self.between(before, after)
+      old_lines = content_lines(before)
+      new_lines = content_lines(after)
+      new(added: without(new_lines, old_lines), removed: without(old_lines, new_lines))
+    end
+
+    # The stored spelling: a JSON object, so a line goes in and comes
+    # back byte for byte whatever it holds. A diff is displayed beside
+    # the entry it belongs to and never queried, so the column is one
+    # value rather than a table of lines.
+    #: (String json) -> CardDiff
+    def self.from_json(json)
+      parsed = JSON.parse(json) #: Hash[String, Array[String]]
+      new(added: parsed.fetch("added"), removed: parsed.fetch("removed"))
+    end
+
+    # A card's lines, each unfolded and stripped of its terminator. The
+    # blank line a card may end with reads as no line at all, the same
+    # answer Parser gives it, so a card's trailing whitespace is not a
+    # line to add or remove.
+    #: (VCard? vcard) -> Array[String]
+    def self.content_lines(vcard)
+      return [] if vcard.nil?
+
+      vcard.lines.filter_map {
+        line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+        line unless line.empty?
+      }
+    end
+
+    # `lines` minus one occurrence of each line `others` also carries.
+    # Order is the card's own, so a diff reads down the card the lines
+    # came from.
+    #: (Array[String] lines, Array[String] others) -> Array[String]
+    def self.without(lines, others)
+      remaining = others.tally
+      lines.reject { |line|
+        count = remaining.fetch(line, 0)
+        next false if count.zero?
+
+        remaining[line] = count - 1
+        true
+      }
+    end
+
+    private_class_method :content_lines, :without
+
+    #: () -> bool
+    def empty? = added.empty? && removed.empty?
+
+    #: () -> String
+    def to_json(*) = {added:, removed:}.to_json
+  end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 7462d26..87d6005 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -5,6 +5,7 @@ require "sequel"
 require "sentry-ruby"
 
 require "pro_tacts/birthday"
+require "pro_tacts/card_diff"
 require "pro_tacts/contact"
 require "pro_tacts/vcard"
 require "pro_tacts/vcard/parser"
@@ -49,10 +50,11 @@ module ProTacts
   class Store
     # @rbs @database: Sequel::Database
 
-    # One entry in the change log. The signature is in
-    # sig/pro_tacts/store.rbs with Store's own.
+    # One entry in the change log: what happened to a card, what it
+    # hashed to afterwards, and which of its lines the write moved. The
+    # signature is in sig/pro_tacts/store.rbs with Store's own.
     # @rbs skip
-    Change = Data.define(:sequence, :card_id, :action, :etag, :created_at)
+    Change = Data.define(:sequence, :card_id, :action, :etag, :diff, :created_at)
 
     # A contact paired with when its card last changed. Contact itself
     # carries no timestamp — it is derived from the card alone, see its
@@ -343,6 +345,11 @@ module ProTacts
       # logged etag is its hash, so a client's token describes the card
       # it downloads.
       inherited = inherited_of(id)
+      # 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 = subtract_inherited(stored, inherited, own)
       contact = Contact.new(id:, stored:, birthday:, inherited:)
       @database.transaction do
@@ -350,7 +357,7 @@ module ProTacts
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
           .insert(id: contact.id, vcard: stored.to_s)
         write_birthday(contact.id, birthday)
-        record(contact.id, "put", contact.etag)
+        record(contact.id, "put", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
         reindex(contact.id, stored)
       end
       contact
@@ -368,25 +375,29 @@ module ProTacts
     # again to reach what the caller already had.
     #: (String id, VCard vcard, birthday: Birthday?) -> Contact
     def rewrite(id, vcard, birthday:)
+      before = contact(id)
       contact = Contact.new(id:, stored: vcard, birthday:, inherited: inherited_of(id))
       @database.transaction do
         cards
           .insert_conflict(target: :id, update: {vcard: Sequel[:excluded][:vcard], updated_at: NOW})
           .insert(id: contact.id, vcard: vcard.to_s)
         write_birthday(contact.id, birthday)
-        record(contact.id, "edit", contact.etag)
+        record(contact.id, "edit", contact.etag, CardDiff.between(before&.vcard, contact.vcard))
         reindex(contact.id, vcard)
       end
       contact
     end
 
     # Removes a card, leaving the change-log entry that tells a syncing
-    # client it is gone.
+    # client it is gone. The tombstone carries the whole card away in
+    # its diff — every line removed and none added — which is the only
+    # record left of what was here, the row itself being gone.
     #: (String id) -> bool
     def delete(id)
       @database.transaction do
+        before = contact(id)
         deleted = cards.where(id:).delete.positive?
-        record(id, "delete", nil) if deleted
+        record(id, "delete", nil, CardDiff.between(before&.vcard, nil)) if deleted
         deleted
       end
     end
@@ -500,9 +511,13 @@ module ProTacts
       SecureRandom.hex(2).tr("0-9a-f", REVERSE_HEX)
     end
 
-    #: (String card_id, String action, String? etag) -> void
-    def record(card_id, action, etag)
-      change_log.insert(card_id:, action:, etag:)
+    # The diff comes in already computed rather than being taken here
+    # 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)
+      change_log.insert(card_id:, action:, etag:, diff: diff.to_json)
     end
 
     # Replaces a card's rows in the index. Rebuilt wholesale rather than
@@ -966,6 +981,7 @@ module ProTacts
         card_id: row.fetch(:card_id).to_s,
         action: row.fetch(:action).to_s,
         etag: row.fetch(:etag),
+        diff: CardDiff.from_json(row.fetch(:diff).to_s),
         created_at: row.fetch(:created_at).to_s,
       )
     end
diff --git a/public/admin.css b/public/admin.css
index 5a378f6..74f686a 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -594,3 +594,23 @@ img.avatar {
 .change-log dd > .type-mono {
   overflow-wrap: anywhere;
 }
+
+/* The lines a write moved, under the etag they hashed to. Removed
+   above added, the order a diff hunk reads in, and the sign in front
+   of each line is what says which — the color is a second telling of
+   it, not the only one, so a row survives being read in one hue. Both
+   wrap for the etag's reason: a line here is a card's, and a card's
+   lines run past 75 octets often enough that the RFC has a folding
+   rule for it. */
+.diff {
+  margin-top: var(--gl-space-2xs);
+}
+.diff > div {
+  overflow-wrap: anywhere;
+}
+.diff-removed {
+  color: var(--gl-color-danger);
+}
+.diff-added {
+  color: var(--gl-color-success);
+}
diff --git a/sig/pro_tacts/card_diff.rbs b/sig/pro_tacts/card_diff.rbs
new file mode 100644
index 0000000..77a1379
--- /dev/null
+++ b/sig/pro_tacts/card_diff.rbs
@@ -0,0 +1,12 @@
+# CardDiff is a Data class, whose members the inline parser cannot
+# read (see sig/pro_tacts/contact.rbs). It is marked `@rbs skip` in
+# card_diff.rb and signed here instead; the methods reopened onto it
+# carry their own inline types.
+module ProTacts
+  class CardDiff
+    attr_reader added: Array[String]
+    attr_reader removed: Array[String]
+
+    def self.new: (added: Array[String], removed: Array[String]) -> instance
+  end
+end
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 69fdbf5..1eb38f8 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -8,9 +8,10 @@ module ProTacts
       attr_reader card_id: String
       attr_reader action: String
       attr_reader etag: String?
+      attr_reader diff: CardDiff
       attr_reader created_at: String
 
-      def self.new: (sequence: Integer, card_id: String, action: String, etag: String?, created_at: String) -> instance
+      def self.new: (sequence: Integer, card_id: String, action: String, etag: String?, diff: CardDiff, created_at: String) -> instance
     end
 
     class RecentContact
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index dac4641..33533a8 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -478,6 +478,36 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # Each entry shows the lines its write moved, removed above added
+  # 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)
+
+      get "/contacts/ada"
+
+      body = last_response.body
+      assert_includes body, '<div class="diff-removed">-FN:Ada Lovelace</div>'
+      assert_includes body, '<div class="diff-added">+FN:Ada L.</div>'
+      assert_operator body.index("-FN:Ada Lovelace"), :<, body.index("+FN:Ada L.")
+    end
+  end
+
+  # A first write's diff is the whole card, so a picture's payload
+  # would be a base64 wall in the log as surely as in the raw card —
+  # and elides the same way. The refuted chunk is a continuation line
+  # of the stored card, contiguous only in an unelided render.
+  def test_the_change_log_elides_a_photo_payload
+    with_contacts({"pic" => PhotoCard.photo("pic", bytes: 256)}) do |store|
+      get "/contacts/pic"
+
+      body = last_response.body
+      assert_includes body, '<div class="diff-added">+PHOTO'
+      refute_includes body, store.contact("pic").vcard.to_s.lines.grep(/\A /).first.to_s
+    end
+  end
+
   # A photo card's raw section elides the base64 wall to its octet
   # count; the property's name and parameters, and every other line,
   # stay byte for byte. The refuted chunk is a continuation line of
diff --git a/test/pro_tacts/test_card_diff.rb b/test/pro_tacts/test_card_diff.rb
new file mode 100644
index 0000000..d2e62b8
--- /dev/null
+++ b/test/pro_tacts/test_card_diff.rb
@@ -0,0 +1,72 @@
+require_relative "../test_helper"
+
+require "pro_tacts/card_diff"
+require "pro_tacts/vcard"
+
+class CardDiffTest < Minitest::Test
+  AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
+
+  def card(bytes) = ProTacts::VCard.new(bytes)
+
+  def between(before, after) = ProTacts::CardDiff.between(before, after)
+
+  def test_a_card_out_of_nothing_adds_every_line
+    diff = between(nil, card(AIDEN))
+
+    assert_equal ["BEGIN:VCARD", "VERSION:3.0", "FN:Aiden", "UID:aiden", "END:VCARD"], diff.added
+    assert_empty diff.removed
+  end
+
+  def test_a_card_going_to_nothing_removes_every_line
+    diff = between(card(AIDEN), nil)
+
+    assert_empty diff.added
+    assert_equal 5, diff.removed.length
+  end
+
+  def test_the_same_card_twice_is_no_change
+    assert between(card(AIDEN), card(AIDEN)).empty?
+  end
+
+  def test_an_edited_line_is_a_removal_and_an_addition
+    diff = between(card(AIDEN), card(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")))
+
+    assert_equal ["FN:Aiden Smith"], diff.added
+    assert_equal ["FN:Aiden"], diff.removed
+  end
+
+  # macOS reorders properties on every card it touches
+  # (docs/macos-contacts.md), so a diff that read position would call a
+  # shuffle a rewrite of everything below the first move.
+  def test_reordering_a_card_is_no_change
+    shuffled = AIDEN.sub("FN:Aiden\r\nUID:aiden\r\n", "UID:aiden\r\nFN:Aiden\r\n")
+
+    assert between(card(AIDEN), card(shuffled)).empty?
+  end
+
+  # Folding is transport (RFC 2426 section 2.6), so the two spellings
+  # of one line are one line.
+  def test_folding_a_line_is_no_change
+    long = AIDEN.sub("FN:Aiden", "NOTE:one two")
+    folded = AIDEN.sub("FN:Aiden", "NOTE:one\r\n  two")
+
+    assert between(card(long), card(folded)).empty?
+  end
+
+  # Counted, not set-differenced: a card carrying a line twice and one
+  # carrying it once are not the same card.
+  def test_dropping_one_of_two_identical_lines_is_a_removal
+    twice = AIDEN.sub("END:VCARD", "NOTE:Same\r\nNOTE:Same\r\nEND:VCARD")
+    once = AIDEN.sub("END:VCARD", "NOTE:Same\r\nEND:VCARD")
+
+    diff = between(card(twice), card(once))
+    assert_equal ["NOTE:Same"], diff.removed
+    assert_empty diff.added
+  end
+
+  def test_a_diff_survives_the_column_it_is_stored_in
+    diff = between(card(AIDEN), card(AIDEN.sub("FN:Aiden", "FN:Aiden \\n Smith")))
+
+    assert_equal diff, ProTacts::CardDiff.from_json(diff.to_json)
+  end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index a9d7da7..e253148 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -445,6 +445,65 @@ class StoreTest < Minitest::Test
     end
   end
 
+  def test_a_first_write_adds_the_whole_card
+    with_store({"aiden" => AIDEN}) do |store|
+      diff = store.changes.last.diff
+
+      assert_equal AIDEN.lines.map(&:chomp), diff.added
+      assert_empty diff.removed
+    end
+  end
+
+  def test_a_writes_diff_records_the_lines_it_moved
+    with_store({"aiden" => AIDEN}) do |store|
+      store.put("aiden", vcard(AIDEN.sub("FN:Aiden", "FN:Aiden Smith")))
+
+      diff = store.changes.last.diff
+      assert_equal ["FN:Aiden Smith"], diff.added
+      assert_equal ["FN:Aiden"], diff.removed
+    end
+  end
+
+  # The tombstone is the only record left of a deleted card, the row
+  # being gone: what it carries away is the whole of what was there.
+  def test_a_tombstone_carries_the_card_away
+    with_store({"aiden" => AIDEN}) do |store|
+      store.delete("aiden")
+
+      diff = store.changes.last.diff
+      assert_equal AIDEN.lines.map(&:chomp), diff.removed
+      assert_empty diff.added
+    end
+  end
+
+  # The diff describes the card a client downloads, the same one the
+  # logged etag hashes — so a birthday that moved to the model, and is
+  # composed back in on every read, is a line the diff records adding
+  # even though no stored card carries it.
+  def test_the_diff_is_of_the_card_as_served
+    with_store({"aiden" => AIDEN}) do |store|
+      store.put("aiden", vcard(AIDEN_BORN))
+
+      assert_equal ["BDAY:1985-04-12"], store.changes.last.diff.added
+    end
+  end
+
+  # A group's line is in the served card either side of a member's
+  # write, so it cancels: what a member's entry records is what the
+  # member's write did, not what it inherited while doing it.
+  def test_a_lent_line_is_no_part_of_a_members_diff
+    with_store({"aiden" => AIDEN}) do |store|
+      add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      served = store.contact("aiden").vcard.to_s
+
+      store.put("aiden", vcard(served.sub("FN:Aiden", "FN:Aiden Smith")))
+
+      diff = store.changes.last.diff
+      assert_equal ["FN:Aiden Smith"], diff.added
+      assert_equal ["FN:Aiden"], diff.removed
+    end
+  end
+
   def test_an_unknown_cards_changes_are_empty
     with_store({"aiden" => AIDEN}) do |store|
       assert_empty store.changes_of("nobody")
@@ -1164,9 +1223,12 @@ class StoreTest < Minitest::Test
   # one when it does.
   def test_the_change_log_admits_the_group_action
     with_store({"aiden" => AIDEN}) do |store|
-      database(store)[:changes].insert(card_id: "aiden", action: "group", etag: "\"x\"")
+      database(store)[:changes]
+        .insert(card_id: "aiden", action: "group", etag: "\"x\"", diff: diff_json(added: ["NOTE:Gate code."]))
 
-      assert_equal "group", store.changes.last.action
+      change = store.changes.last
+      assert_equal "group", change.action
+      assert_equal ["NOTE:Gate code."], change.diff.added
     end
   end
 
@@ -1418,11 +1480,12 @@ class StoreTest < Minitest::Test
 
   ## The migration
 
-  # A database at the old action vocabulary — put and delete only — is
-  # carried over with its sequences intact: they are every client's
-  # sync token state, and a rebuilt table that skipped or reused one
-  # would silently drop a change from some client's window.
-  def test_the_migration_carries_the_change_log_and_its_sequences
+  # A database from before the diff column loses its log to 006, which
+  # is what that migration trades for a column that is never null: an
+  # entry written then has no diff and no way to be given one. The
+  # sequence restarts with it, and the widened action vocabulary is
+  # still there on the other side.
+  def test_the_diff_migration_drops_the_change_log
     Dir.mktmpdir do |dir|
       path = Pathname.new(dir) / "contacts.db"
       Sequel.connect("sqlite://#{path}") do |db|
@@ -1432,16 +1495,13 @@ class StoreTest < Minitest::Test
       end
 
       ProTacts::Store.connect(path) do |store|
-        assert_equal [%w[aiden put], %w[aiden delete]], store.changes.map { [it.card_id, it.action] }
-        assert_equal [1, 2], store.changes.map(&:sequence)
+        assert_empty store.changes
 
-        # The widened vocabulary admits the editor's action, and the
-        # sequence continues past the copied high-water mark.
         store.put("aiden", vcard(AIDEN))
         store.rewrite("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 4, change.sequence
+        assert_equal 2, change.sequence
       end
     end
   end
@@ -1518,7 +1578,7 @@ class StoreTest < Minitest::Test
   class FailingLog < ProTacts::Store
     private
 
-    def record(card_id, action, etag)
+    def record(card_id, action, etag, diff)
       raise "the change log is unavailable"
     end
   end
@@ -1530,6 +1590,11 @@ class StoreTest < Minitest::Test
     store.instance_variable_get(:@database)
   end
 
+  # A diff column's value, for a row inserted around the store.
+  def diff_json(added: [], removed: [])
+    ProTacts::CardDiff.new(added:, removed:).to_json
+  end
+
   def card_row(store, id)
     database(store)[:cards].where(id:).sole
   end