M311 step 1 — lift a tag that is standing on its own
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Failing after 9s
CI & Build / Build & push image (push) Skipped

A tag was shown twice: once as the `#todo` you typed and once as a chip. The
chip moved to the top of the card in 23fd2da; now the text goes — but only
when the tag was the whole line.

THE RULE: a line containing nothing but tags and whitespace is removed.
Anything else is untouched.

That is the conservative reading of "standalone" and it is the operator's:
"only lift standalone tags, leave mid-sentence ones alone". The looser
reading, also stripping a trailing tag off a prose line, is rejected because
the text does not say which kind it is — `buy milk #grocery` is filing,
`remember to call #mom` is the sentence's object, and lifting the second
leaves "remember to call". Mangling a sentence to save a duplicate chip is a
bad trade.

Two guards. A line inside a ``` fence is never touched: a `#tag` there is a
shell comment in somebody's snippet, and deleting it would eat a line of
their example. And a note that is NOTHING but tags keeps its text rather than
being blanked — a duplicated chip beats an empty card.

WHY THIS IS NOT JUST A TEXT EDIT. `via_tag` labels are DERIVED from the body:
reconcile detaches any row no longer backed by a `#tag`, and the picker only
manages `via_tag=False` rows. So a naive lift deletes every tag on the next
save, and leaves them unremovable until then.

Resolved by giving `via_tag` a sharper meaning — backed by text still in the
body — rather than deleting it:

  standalone  lifted, attached as an ORDINARY label. Nothing derives it any
              more because nothing is left to derive it from.
  inline      left in place, still derived, still detached when its text goes.

Which costs nothing elsewhere, because both editors already gate their remove
button on `!via_tag` (NoteEditor.vue:618, EditorChrome.kt:349). A lifted tag
gets its × for free — and needs it, since deleting the text is no longer a
way to remove one. No wire change, no column drop, no UI change.

A tag that GRADUATES from inline to standalone is the sharp edge: its row has
to be flipped before the detach pass, or the same row is dropped for no longer
being in the body. That is the bug, and there is a test on it.

The lift and the display_title re-derivation both live inside the function,
which is renamed to admit it mutates the body. All seven call sites derive
display_title BEFORE calling, so anywhere else and every note would be named
after a line that had just been deleted. Spreading a derived-value update
across seven write paths is the failure #2965 named: "easy to miss, and it is
the common one".

Existing notes lift lazily, on their next save. The migration that does the
rest is step 2, and the core's own copy of the rule is step 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:44:43 -04:00
co-authored by Claude Opus 5
parent 23fd2da91e
commit ad48d30c68
7 changed files with 284 additions and 45 deletions
+70
View File
@@ -25,8 +25,10 @@ from sqlalchemy import select, text
from thoughtsync import ratelimit
from thoughtsync.app import create_app
from thoughtsync.db import dispose_engine, session_scope
from thoughtsync.models.label import NoteLabel
from thoughtsync.models.note import Note
from thoughtsync.models.user import User
from thoughtsync.notes.tags import _lift_and_reconcile_tags
from thoughtsync.settings import get_setting, live, refresh_live, reset_live, set_settings
from thoughtsync.notes.checklist import parse_items, set_item_checked
from thoughtsync.notes.helpers import derive_display_title
@@ -199,6 +201,74 @@ async def test_ticking_an_item_is_a_body_edit(db, owner):
assert stored.splitlines()[0] == "packing"
async def test_a_standalone_tag_leaves_the_body_and_becomes_an_ordinary_label(db, owner):
"""M311. The tag was being shown twice — as text and as a chip — so the text goes.
`via_tag=False` is the load-bearing half. It is what makes the chip's × appear in
both editors (they gate it on exactly this), which matters because deleting the
text is no longer a way to remove the tag: there is no text.
"""
note = Note(owner_id=owner.id, body="#todo\nreorganize the homepage", display_title="#todo")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert note.body == "reorganize the homepage"
# Re-derived by the lift itself. Every caller sets display_title BEFORE calling,
# so if the function did not do this the note would be named after a line it had
# just deleted.
assert note.display_title == "reorganize the homepage"
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is False
async def test_a_tag_moved_onto_its_own_line_graduates_instead_of_vanishing(db, owner):
"""The bug a naive lift has, pinned.
A tag that is still in prose stays derived. Move it to its own line and it must
become an ordinary label — NOT be detached for no longer appearing in the body,
which is what happens if the row is dropped before it is graduated.
"""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1
assert rows[0].via_tag is True
assert note.body == "call #mom tomorrow", "a tag inside a sentence is left alone"
note.body = "#mom\ncall tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
assert len(rows) == 1, "the label survived the move"
assert rows[0].via_tag is False
assert note.body == "call tomorrow"
async def test_deleting_an_inline_tag_still_detaches_it(db, owner):
"""The old behaviour, unchanged where the text is unchanged. A tag still living in
prose is still owned by that prose."""
note = Note(owner_id=owner.id, body="call #mom tomorrow", display_title="call #mom tomorrow")
db.add(note)
await db.flush()
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert len((await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()) == 1
note.body = "call tomorrow"
await _lift_and_reconcile_tags(db, note)
await db.commit()
assert (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all() == []
async def test_a_note_with_only_a_list_still_has_a_name(db, owner):
"""The hole that made removing the title unsafe, still closed — by a different
mechanism. There is no item table to fall back to any more; the name comes from