A deleted tag is a tombstone on every path, and the web skips tombstones

The web deleted a tag's row outright (delete, and merge's source), so the change
feed never carried it and linked devices kept the tag. A device's delete left a
tombstone that the web still listed, matched by name on create and rename, minted
#tags onto, and accepted in a picker.

- labeling.tombstone_label is the one way a tag is deleted: drop its links, set
  purged_at. REST delete, merge and sync's op=delete all use it.
- labeling.live(owner) is the one definition of a tag that exists; every catalog
  read uses it (list, lookup, create/rename matching, #tag minting, picker ids,
  export, and sync's name-clash check).
- 0040: (owner_id, name) is unique among live tags only, so a tombstone gives its
  name back and #grocery can be made again, on the web or from a device.

Fixes #5382.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:08:54 -04:00
co-authored by Claude Opus 5.5
parent be4897276c
commit 8eff5f60a6
8 changed files with 191 additions and 32 deletions
+85
View File
@@ -646,6 +646,91 @@ async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one
assert len(listing) == 1
# --- A deleted tag is a tombstone on every path (#5382) ---------------------------
#
# The web deleted the row outright, so the change feed never carried the delete and
# devices kept the tag. A device's delete left a tombstone the web still listed and
# still matched by name. One definition now: `labeling.tombstone_label`, and every
# read of the catalog goes through `labeling.live`.
async def _label_in_feed(app_client, lid: str) -> dict | None:
feed = await (await app_client.get("/api/sync/changes?since=0")).get_json()
return next((lb for lb in feed["labels"] if lb["id"] == lid), None)
async def _push_label(app_client, lid: str, op: str, edited_at: str, name: str = "grocery") -> dict:
change = {"entity": "label", "id": lid, "op": op, "name": name, "edited_at": edited_at}
resp = await app_client.post("/api/sync/push", json={"changes": [change]})
return (await resp.get_json())["results"][0]
async def test_a_tag_deleted_on_the_web_reaches_devices_as_a_tombstone(app_client, db):
await _signed_in(app_client, "webdelete")
tag = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
note = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
await app_client.put(f"/api/notes/{note['id']}/labels", json={"label_ids": [tag["id"]]})
assert (await app_client.delete(f"/api/labels/{tag['id']}")).status_code == 200
row = await _label_in_feed(app_client, tag["id"])
assert row is not None and row["purged_at"] is not None, "the feed carries the delete"
assert (await (await app_client.get(f"/api/notes/{note['id']}")).get_json())["labels"] == []
assert (await (await app_client.get("/api/labels")).get_json())["labels"] == []
assert (await app_client.delete(f"/api/labels/{tag['id']}")).status_code == 404, "gone, not hidden"
async def test_a_merge_tombstones_the_tag_it_folds_away(app_client, db):
await _signed_in(app_client, "mergedelete")
source = await (await app_client.post("/api/labels", json={"name": "groceries"})).get_json()
target = await (await app_client.post("/api/labels", json={"name": "shopping"})).get_json()
resp = await app_client.post(f"/api/labels/{source['id']}/merge", json={"into": target["id"]})
assert resp.status_code == 200
row = await _label_in_feed(app_client, source["id"])
assert row is not None and row["purged_at"] is not None
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert [lb["id"] for lb in listing] == [target["id"]]
async def test_a_tag_deleted_on_a_device_is_gone_from_the_web_and_its_name_is_free(app_client, db):
await _signed_in(app_client, "devicedelete")
lid = str(uuid.uuid4())
assert (await _push_label(app_client, lid, "upsert", "2026-01-01T00:00:00Z"))["status"] == "created"
assert (await _push_label(app_client, lid, "delete", "2026-01-02T00:00:00Z"))["status"] == "applied"
assert (await (await app_client.get("/api/labels")).get_json())["labels"] == []
# The web makes the name again: a NEW live tag, not the tombstone handed back.
again = await app_client.post("/api/labels", json={"name": "Grocery"})
assert again.status_code == 201
assert (await again.get_json())["id"] != lid
# A body #tag finds that live one, not the tombstone.
note = await (await app_client.post("/api/notes", json={"body": "milk #grocery"})).get_json()
note = await (await app_client.get(f"/api/notes/{note['id']}")).get_json()
assert [lb["id"] for lb in note["labels"]] == [(await again.get_json())["id"]]
# A picker naming the tombstone attaches nothing.
await app_client.put(f"/api/notes/{note['id']}/labels", json={"label_ids": [lid]})
labels = (await (await app_client.get(f"/api/notes/{note['id']}")).get_json())["labels"]
assert lid not in [lb["id"] for lb in labels]
async def test_a_device_can_make_a_deleted_tags_name_again(app_client, db):
"""The tombstone gives its name back (0040), so another device's fresh tag of
the same name is created rather than refused as "name in use"."""
await _signed_in(app_client, "devicerename")
old, new = str(uuid.uuid4()), str(uuid.uuid4())
await _push_label(app_client, old, "upsert", "2026-01-01T00:00:00Z")
await _push_label(app_client, old, "delete", "2026-01-02T00:00:00Z")
assert (await _push_label(app_client, new, "upsert", "2026-01-03T00:00:00Z"))["status"] == "created"
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
assert [lb["id"] for lb in listing] == [new]
# --- One save path for a note's text (#5164) ---------------------------------
#
# A body edit is a sequence: keep a revision, rename the note, lift #tags, commit,