v0.14.0: a removal you make sticks

Round 43 made the merge report honest, and in doing so made its one real
limitation impossible to miss: every merge was a union, so a song removed from a
playlist on one machine was handed straight back by the other on the next sync,
and a track deleted from the library came back with it. A deletion you cannot
make stick is not a deletion.

The union was there for a real reason — two copies with no common ancestor
cannot tell "A added this" from "B removed it" — so the missing evidence is
written down instead of inferred. New lintunes/tombstones.py: a playlist keeps
track_events {track id: [when, add|remove]}, the library keeps deleted_tracks
{track id: when} in library_metadata.json, and a merge applies the newest event
per track across both copies. A removal beats a copy that merely still had the
song; a deliberate re-add afterwards beats the removal; a track nobody touched
still merges as a union, which stays the safe behavior where there is no
evidence either way.

Deliberately not "the newer copy wins wholesale": that one-liner silently drops
a song the other machine added while you were removing one, which
test_an_unrelated_addition_is_not_lost pins.

Events are recorded in the funnels that already exist — _set_track_ids diffs
before/after so a reorder records nothing, _remove_tracks stamps the library,
_restore_tracks clears it so Ctrl+Z takes the tombstone back — and pruned after
30 days at the save boundary.

Three edges worth naming:

* Track ids are never reused. The next id came from max(library.tracks), so
  deleting the highest-numbered track freed its id, and the next import would be
  dropped on sight by the dead id's own tombstone on every machine.
* library_metadata.json is merged before library.json, since it carries the
  record the library merge is filtered against and rglob order is not a plan.
* A merge applying a deletion never touches a music file — it drops the library
  entry only, and test_a_merge_never_touches_a_music_file fails the run if
  send_to_trash is so much as called. Applied removals grade WARNING and name
  the song.

Claude-Session: https://claude.ai/code/session_01Y9ZEFi4qNJ39FMiBtiAxy2
This commit is contained in:
2026-08-27 18:31:33 -04:00
parent e6bd0de4e3
commit 5831175584
12 changed files with 594 additions and 26 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.13.0"
__version__ = "0.14.0"
+2 -1
View File
@@ -25,7 +25,8 @@ from lintunes.storage.conflict_resolver import (
INTRO = ("LinTunes found changes made on more than one machine and merged them "
"(play counts kept highest, newest edits win, nothing removed). Both "
"(play counts kept highest, newest edits win, and a removal made on "
"either machine is applied on both). Both "
"versions were backed up first — restore them if a merge isn't what "
"you wanted. The backup folder holds what-changed.txt: the same merge "
"in full, every track named.")
+34 -4
View File
@@ -8,7 +8,7 @@ from PyQt6.QtCore import QObject, QTimer, pyqtSignal
log = logging.getLogger(__name__)
from lintunes import music_folder as music_folder_mod
from lintunes import smart, tagging, trash
from lintunes import smart, tagging, tombstones, trash
from lintunes.importers.file_importer import organized_destination, unique_path
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.paths import to_relative
@@ -96,13 +96,19 @@ class LibraryManager(QObject):
self._own_sigs: dict[str, tuple | None] = {}
self.snapshot_sync_state()
self._max_track_id = max(self.library.tracks.keys(), default=0)
self._max_track_id = self._highest_track_id()
# ---- id generation ----
def new_track_id(self) -> int:
return self._max_track_id + 1
def _highest_track_id(self) -> int:
"""The highest id ever used, deletions included. Ids must never be
reused: a new track handed a deleted one's id would be dropped on sight
by that id's own tombstone (and on every other machine too)."""
return max([*self.library.tracks, *self.library.deleted_tracks], default=0)
def new_persistent_id(self) -> str:
while True:
pid = secrets.token_hex(8).upper()
@@ -336,6 +342,7 @@ class LibraryManager(QObject):
playlist = self.library.playlists.get(pid)
if playlist is None:
return
before = list(playlist.track_ids)
playlist.track_ids = list(ids)
# The single funnel for every content change (add / remove / reorder /
# undo). date_modified moves only where content genuinely changed: here,
@@ -347,6 +354,10 @@ class LibraryManager(QObject):
# conflict_resolver._playlist_newer.
if touch:
playlist.date_modified = _utc_now_iso()
# Record *what* changed, not just that something did. A merge has no
# common ancestor, so without this a removal is indistinguishable
# from the other copy's addition and the union hands the song back.
tombstones.record(playlist.track_events, before, playlist.track_ids)
self._mark_playlist(pid, content=True)
self.playlist_content_changed.emit(pid)
@@ -465,8 +476,13 @@ class LibraryManager(QObject):
def _remove_tracks(self, tracks: list, snapshots: dict[str, list[int]]):
doomed = {t.track_id for t in tracks}
when = _utc_now_iso()
for track in tracks:
self.library.tracks.pop(track.track_id, None)
# Same reason as the playlist events: library.json merges as a
# union, so a deletion the other copy hasn't heard about comes
# straight back without a record that it was deliberate.
self.library.deleted_tracks[track.track_id] = when
self._invalidate_artwork(track)
for pid in snapshots:
playlist = self.library.playlists.get(pid)
@@ -482,6 +498,7 @@ class LibraryManager(QObject):
def _restore_tracks(self, tracks: list, snapshots: dict[str, list[int]]):
for track in tracks:
self.library.tracks[track.track_id] = track
self.library.deleted_tracks.pop(track.track_id, None)
self._max_track_id = max(self._max_track_id, track.track_id)
for pid, ids in snapshots.items():
if pid in self.library.playlists:
@@ -1016,6 +1033,15 @@ class LibraryManager(QObject):
# amount equal to this machine's journal.
self.play_journal.load(self.data_dir, disk)
# Deletion records are additive on both sides: ours are not on disk yet
# if we haven't flushed, and theirs are news to us. Union first, then
# apply, so a track deleted on either machine stays deleted on both.
self.library.deleted_tracks = tombstones.merge_events(
self.library.deleted_tracks, disk.deleted_tracks)
for tid in self.library.deleted_tracks:
self.library.tracks.pop(tid, None)
disk.tracks.pop(tid, None)
for tid, disk_track in disk.tracks.items():
mem_track = self.library.tracks.get(tid)
if mem_track is None:
@@ -1047,7 +1073,7 @@ class LibraryManager(QObject):
self.library.library_settings = disk.library_settings
self.refresh_music_folder()
self._max_track_id = max(self.library.tracks.keys(), default=0)
self._max_track_id = self._highest_track_id()
self._recomputing = True
try:
@@ -1098,6 +1124,8 @@ def _reconcile_playlist(mem_pl, disk_pl):
other machine was quietly undone. Same rule as
conflict_resolver._playlist_newer, one layer up."""
from lintunes.storage.conflict_resolver import merge_track_order
mem_pl.track_events = tombstones.merge_events(mem_pl.track_events,
disk_pl.track_events)
ours, theirs = mem_pl.date_modified, disk_pl.date_modified
disk_newer = bool(theirs) and (not ours or theirs > ours)
mine, other = list(mem_pl.track_ids), list(disk_pl.track_ids)
@@ -1116,7 +1144,9 @@ def _reconcile_playlist(mem_pl, disk_pl):
# so is what lets the other machine adopt it instead of the two trading
# the same tracks back and forth.
mem_pl.date_modified = _utc_now_iso()
mem_pl.track_ids = merged
# A union would hand back anything the newest event says was removed.
gone = tombstones.removed_ids(mem_pl.track_events)
mem_pl.track_ids = [tid for tid in merged if tid not in gone]
def _utc_now_iso() -> str:
+4
View File
@@ -24,6 +24,10 @@ class Library:
music_folder_rel: str = ""
music_folder_set_at: Optional[str] = None
import_date: Optional[str] = None
# {track_id: when} for tracks deleted from the library inside the retention
# window. A merge unions two copies of library.json, so without this a track
# deleted here is handed straight back by the other machine's copy.
deleted_tracks: dict = field(default_factory=dict)
# Column/sort settings for the all-tracks Library view
library_settings: PlaylistSettings = field(
default_factory=lambda: PlaylistSettings(sort_column="artist"))
+10
View File
@@ -62,6 +62,10 @@ class Playlist:
# for column widths or sort order — the conflict merge uses it to decide
# whose order wins, and the file's mtime moves for cosmetic reasons.
date_modified: Optional[str] = None
# {track_id: [when, "add"|"remove"]} for membership changes inside the
# retention window. Without it a merge is a union and a song removed on one
# machine comes back from the other forever; see lintunes/tombstones.py.
track_events: dict = field(default_factory=dict)
# Set only for SMART playlists. Membership (track_ids) is derived from this
# by the LibraryManager; for imported playlists track_ids also doubles as a
# snapshot fallback when the criteria couldn't be fully parsed.
@@ -99,6 +103,10 @@ class Playlist:
d["parent_persistent_id"] = self.parent_persistent_id
if self.date_modified:
d["date_modified"] = self.date_modified
if self.track_events:
# JSON object keys are strings; track ids are ints everywhere else.
d["track_events"] = {str(tid): event
for tid, event in self.track_events.items()}
if self.smart_criteria is not None:
d["smart_criteria"] = self.smart_criteria.to_dict()
return d
@@ -118,6 +126,8 @@ class Playlist:
settings=PlaylistSettings.from_dict(d.get("settings", {})),
is_system=d.get("is_system", False),
date_modified=d.get("date_modified"),
track_events={int(tid): list(event)
for tid, event in (d.get("track_events") or {}).items()},
smart_criteria=smart_criteria,
)
+108 -18
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from lintunes import sync_identity
from lintunes import sync_identity, tombstones
from lintunes.storage.json_storage import read_json, write_json
@@ -96,6 +96,11 @@ def resolve_conflicts(data_dir: Path) -> list[ConflictSummary]:
conflict_files = _find_conflict_files(data_dir)
if not conflict_files:
return []
# library_metadata.json carries the record of what was deleted, and
# library.json is merged against it — so it has to be merged first, whatever
# order rglob happened to hand them back in.
conflict_files.sort(
key=lambda item: 0 if item.original_path.name == "library_metadata.json" else 1)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_dir = data_dir / ".resolved" / stamp
@@ -120,7 +125,8 @@ def resolve_conflicts(data_dir: Path) -> list[ConflictSummary]:
"the other copy is in the backup."],
level=WARNING)
elif original_path.name == "library.json":
summary = _merge_library(original_path, conflict_path)
summary = _merge_library(original_path, conflict_path,
data_dir, label)
elif original_path.name == "library_metadata.json":
summary = _merge_metadata(original_path, conflict_path)
elif original_path.parent.name == "playlists":
@@ -238,14 +244,18 @@ def _track_labels(data_dir: Path) -> dict[str, str]:
labels = {}
try:
for tid, track in read_json(data_dir / "library.json").items():
title = track.get("name") or f"track {tid}"
artist = track.get("artist") or track.get("album_artist")
labels[str(tid)] = f"{artist} — {title}" if artist else title
labels[str(tid)] = _label_for(tid, track)
except Exception:
pass # no library yet, or an unreadable one: fall back to bare ids
return labels
def _label_for(tid, track: dict) -> str:
title = track.get("name") or f"track {tid}"
artist = track.get("artist") or track.get("album_artist")
return f"{artist} — {title}" if artist else title
class _Labeler:
"""Names tracks for the report, loading library.json only if asked."""
@@ -258,6 +268,13 @@ class _Labeler:
self._labels = _track_labels(self._data_dir)
return self._labels.get(str(tid)) or f"track {tid}"
def remember(self, tid, track: dict):
"""Keep a name that is about to be deleted from library.json — the
playlist merge still has to say which song it dropped."""
if self._labels is None:
self._labels = _track_labels(self._data_dir)
self._labels.setdefault(str(tid), _label_for(tid, track))
def _local_time(stamp: str | None) -> str:
"""A naive-UTC ISO stamp as local wall-clock, the way the window shows it."""
@@ -278,14 +295,28 @@ def _utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
def _merge_library(original_path: Path, conflict_path: Path) -> ConflictSummary:
def _merge_library(original_path: Path, conflict_path: Path,
data_dir: Path = None, label=None) -> ConflictSummary:
original = read_json(original_path)
conflict = read_json(conflict_path)
# Deliberate deletions, from the file merged just before this one. A union
# without them means a track deleted on either machine is handed back by
# whichever copy hadn't heard yet — every time, forever.
deleted = _deleted_tracks(data_dir)
added = 0
changed = 0
removed = 0
examples: list[str] = []
for tid in list(original):
if tid in deleted:
if label is not None:
label.remember(tid, original[tid])
del original[tid]
removed += 1
for tid, conflict_track_data in conflict.items():
if tid in deleted:
continue
if tid not in original:
original[tid] = conflict_track_data
added += 1
@@ -305,11 +336,29 @@ def _merge_library(original_path: Path, conflict_path: Path) -> ConflictSummary:
"(play counts kept highest, newest edits win).")
if added:
lines.append(f"{added} track(s) present only on the other machine were kept.")
if removed:
lines.append(f"{_count(removed)} deleted from the library stayed deleted, "
"though the other copy still listed "
f"{'it' if removed == 1 else 'them'}. "
"No file on this machine was touched.")
if not lines:
lines.append("No differences needed reconciling.")
lines.extend(examples)
return ConflictSummary("library.json", "library", lines,
level=CHANGE if (changed or added) else INFO)
return ConflictSummary(
"library.json", "library", lines,
level=WARNING if removed else (CHANGE if (changed or added) else INFO))
def _deleted_tracks(data_dir: Path | None) -> dict:
"""The library's deletion record, keyed the way library.json is (strings)."""
if data_dir is None:
return {}
try:
metadata = read_json(data_dir / "library_metadata.json")
except (ValueError, OSError):
return {}
return {str(tid): when
for tid, when in (metadata.get("deleted_tracks") or {}).items()}
# Every field _merge_track_fields reads or writes. If two copies of a track
@@ -443,6 +492,12 @@ def _playlist_newer(original: dict, conflict: dict,
"timestamps instead.")
def _events(playlist: dict) -> dict:
"""A playlist file's membership events, with ids back in int form."""
return {int(tid): event
for tid, event in (playlist.get("track_events") or {}).items()}
def _positions(merged: list, tids, label) -> list[str]:
""""Artist — Title → position 12, after “…”" for each of `tids`."""
index = {}
@@ -531,6 +586,17 @@ def _merge_playlist(original_path: Path, conflict_path: Path,
merged = merge_track_order(conf_ids, orig_ids)
else:
merged = merge_track_order(orig_ids, conf_ids)
# A union alone can never honor a removal: with no common ancestor, "they
# removed it" and "we added it" look identical from the two lists. The
# newest recorded event per track breaks the tie — a removal beats a copy
# that merely still had the song, and a later re-add beats the removal.
events = tombstones.merge_events(_events(original), _events(conflict))
gone = tombstones.removed_ids(events) & set(merged)
merged = [tid for tid in merged if tid not in gone]
if events:
original["track_events"] = {str(tid): event
for tid, event in events.items()}
original["track_ids"] = merged
renamed_from = ""
@@ -567,9 +633,10 @@ def _merge_playlist(original_path: Path, conflict_path: Path,
# merge made the copy in place harder to beat on the next one.
write_json(original_path, original)
added = [tid for tid in conf_set - orig_set]
only_here = [tid for tid in orig_set - conf_set]
if orig_ids == conf_ids:
added = [tid for tid in conf_set - orig_set if tid not in gone]
only_here = [tid for tid in orig_set - conf_set if tid not in gone]
removed = sorted(gone)
if orig_ids == conf_ids and not gone:
# The two copies held the same tracks in the same order, so they differed
# only in column widths or sort order — a window resize, not an edit.
# Note this is a test on the *inputs*: a merge whose result happens to
@@ -610,19 +677,34 @@ def _merge_playlist(original_path: Path, conflict_path: Path,
# no way to tell which. Both readings are offered rather than guessed.
headline = (f"{_count(len(only_here))} here that the other copy did "
f"not have {'was' if len(only_here) == 1 else 'were'} "
"kept — a merge never removes:")
it, they, them = (("it", "it", "it") if len(only_here) == 1
else ("them", "they", "them"))
note = (f"If you added {it} here, that's all this is. If you deleted "
f"{it} on the other machine, delete {them} here too or "
f"{they} will come back on the next merge.")
"kept — nothing recorded a removal:")
it, they = ("it", "it") if len(only_here) == 1 else ("them", "they")
note = (f"Most likely you added {it} here. A removal made on the other "
f"machine carries across on its own now, so {they} would have "
"gone already — unless it happened before that machine "
f"updated, or longer ago than {tombstones.RETENTION_DAYS} days.")
lines.append(headline)
lines.extend(_capped(entries))
lines.append(note)
detail.append(headline)
detail.extend(f" • {entry}" for entry in entries)
detail.append(note)
return ConflictSummary(name, "playlist", lines, level=CHANGE, detail=detail)
if removed:
entries = [label(tid) for tid in removed]
headline = (f"{_count(len(removed))} removed from this playlist on the "
"other machine "
f"{'was' if len(removed) == 1 else 'were'} removed here too:")
note = ("Undo (Ctrl+Z) does not reach a merge — add "
f"{'it' if len(removed) == 1 else 'them'} back from the library "
"if this wasn't what you wanted. No file was touched.")
lines.append(headline)
lines.extend(_capped(entries))
lines.append(note)
detail.append(headline)
detail.extend(f" • {entry}" for entry in entries)
detail.append(note)
return ConflictSummary(name, "playlist", lines,
level=WARNING if removed else CHANGE, detail=detail)
def _merge_play_journal(original_path: Path, conflict_path: Path) -> ConflictSummary:
@@ -666,6 +748,7 @@ def _merge_play_journal(original_path: Path, conflict_path: Path) -> ConflictSum
# What library_metadata.json actually holds, in words a person recognizes.
_METADATA_LABELS = {
"library_settings": "column layout",
"deleted_tracks": "record of deleted songs",
"music_folder": "music folder",
"music_folder_rel": "music folder",
"music_folder_set_at": "music folder",
@@ -701,6 +784,13 @@ def _merge_metadata(original_path: Path, conflict_path: Path) -> ConflictSummary
# machine can silently revert a folder change made on the other.
if before is not None and other is not None:
kept = read_json(original_path)
# Deletions are additive: whichever whole copy the mtime pick kept, the
# other one's record of what was deleted is still true.
union = tombstones.merge_events(before.get("deleted_tracks") or {},
other.get("deleted_tracks") or {})
if union != (kept.get("deleted_tracks") or {}):
kept["deleted_tracks"] = union
write_json(original_path, kept)
# Decide the folder from the two *inputs*, never from whichever copy the
# mtime pick above landed on: when both files are written in the same
# instant that pick is a coin flip, and it was deciding a deliberate
+25
View File
@@ -1,6 +1,7 @@
import json
from pathlib import Path
from lintunes import tombstones
from lintunes.models import Track, Playlist, PlaylistSettings, Library
from lintunes.paths import to_relative, to_absolute
@@ -66,6 +67,10 @@ def save_metadata(library: Library, data_dir: Path):
"track_count": len(library.tracks),
"playlist_count": len(library.playlists),
"library_settings": library.library_settings.to_dict(),
# Pruned at the boundary, like the derived-membership gate below: the
# in-memory dict stays honest, the file doesn't grow forever.
"deleted_tracks": {str(tid): when for tid, when
in tombstones.prune(library.deleted_tracks).items()},
}
write_json(data_dir / "library_metadata.json", metadata)
@@ -74,6 +79,10 @@ def save_playlist(playlist: Playlist, data_dir: Path):
playlists_dir = data_dir / "playlists"
playlists_dir.mkdir(parents=True, exist_ok=True)
data = playlist.to_dict()
if data.get("track_events"):
data["track_events"] = tombstones.prune(data["track_events"])
if not data["track_events"]:
del data["track_events"]
if playlist.has_derived_membership:
# Membership is a pure function of the criteria and the library, and
# every machine recomputes it on load — storing it only bought a
@@ -113,6 +122,7 @@ def load_library(data_dir: Path) -> Library:
music_folder_rel = ""
music_folder_set_at = None
import_date = None
deleted_tracks = {}
library_settings = PlaylistSettings()
metadata_path = data_dir / "library_metadata.json"
if metadata_path.exists():
@@ -121,6 +131,8 @@ def load_library(data_dir: Path) -> Library:
music_folder_rel = metadata.get("music_folder_rel", "")
music_folder_set_at = metadata.get("music_folder_set_at")
import_date = metadata.get("import_date")
deleted_tracks = {int(tid): when for tid, when
in (metadata.get("deleted_tracks") or {}).items()}
if "library_settings" in metadata:
library_settings = PlaylistSettings.from_dict(metadata["library_settings"])
@@ -133,9 +145,22 @@ def load_library(data_dir: Path) -> Library:
playlist = Playlist.from_dict(playlist_data)
playlists[playlist.persistent_id] = playlist
# A deleted track can still be sitting in a library.json that synced in
# from a machine which hasn't heard about the deletion yet, and in the
# track_ids of any playlist that held it. Applying the record here covers
# every load path at once — startup and the reload_from_disk reconcile.
for tid in deleted_tracks:
tracks.pop(tid, None)
if deleted_tracks:
for playlist in playlists.values():
if any(tid in deleted_tracks for tid in playlist.track_ids):
playlist.track_ids = [tid for tid in playlist.track_ids
if tid not in deleted_tracks]
return Library(
tracks=tracks,
playlists=playlists,
deleted_tracks=deleted_tracks,
music_folder=music_folder,
music_folder_rel=music_folder_rel,
music_folder_set_at=music_folder_set_at,
+89
View File
@@ -0,0 +1,89 @@
"""Removals that survive a merge.
Two copies of a playlist and no common ancestor cannot tell "A added this song"
apart from "B deleted it" — which is why every merge until now was a union, and
why a song removed on one machine came back from the other on the next sync.
A deletion you cannot make stick is not a deletion.
So the removal is recorded rather than inferred: each playlist keeps a small
``track_events`` map of ``{track id: (when, "add"|"remove")}``, and the library
keeps ``deleted_tracks``. A merge takes the *newest* event per track across both
copies and applies it, so a removal beats a copy that simply still had the song,
and a deliberate re-add afterwards beats the removal. Tracks nobody has touched
have no event and keep the old union behavior.
Events are pruned after ``RETENTION_DAYS``: by then every machine has seen them,
and keeping them forever would grow the file for nothing. The trade is that a
machine offline longer than that can resurrect a song it never learned was
deleted — which is the same trade every sync system makes, and the merge window
says out loud when a removal is applied.
"""
from datetime import datetime, timedelta, timezone
ADD = "add"
REMOVE = "remove"
# How long a membership event is kept. Long enough that a laptop shut in a bag
# for a month still learns about the deletion; short enough that the map stays
# a handful of entries.
RETENTION_DAYS = 30
def now_iso() -> str:
"""Naive UTC ISO — the one stamp format every machine writes, so the strings
sort correctly against each other without parsing."""
return datetime.now(timezone.utc).replace(tzinfo=None).isoformat()
def _expiry(now: str | None = None) -> str:
base = datetime.fromisoformat(now) if now else datetime.now(timezone.utc).replace(tzinfo=None)
return (base - timedelta(days=RETENTION_DAYS)).isoformat()
def prune(events: dict, now: str | None = None) -> dict:
"""Drop events older than the retention window."""
cutoff = _expiry(now)
return {key: value for key, value in events.items()
if _stamp(value) and _stamp(value) >= cutoff}
def _stamp(value):
"""An event is (when, op); a bare stamp means a deletion in deleted_tracks."""
if isinstance(value, (list, tuple)):
return value[0] if value else None
return value
def _op(value):
if isinstance(value, (list, tuple)) and len(value) > 1:
return value[1]
return REMOVE
def merge_events(ours: dict, theirs: dict) -> dict:
"""Newest event per track wins. Works for ``track_events`` (stamp + op) and
for ``deleted_tracks`` (bare stamps) alike."""
merged = dict(ours or {})
for key, value in (theirs or {}).items():
mine = merged.get(key)
if mine is None or (_stamp(value) or "") > (_stamp(mine) or ""):
merged[key] = value
return merged
def removed_ids(events: dict) -> set:
"""Which tracks the newest event says are gone."""
return {key for key, value in (events or {}).items() if _op(value) == REMOVE}
def record(events: dict, before, after) -> dict:
"""Note what a membership change did, in place. ``before``/``after`` are the
two track_ids lists; a reorder produces no events at all."""
was, now = set(before), set(after)
stamp = now_iso()
for tid in was - now:
events[tid] = [stamp, REMOVE]
for tid in now - was:
events[tid] = [stamp, ADD]
return events