"""note_supersessions; drop the never-written notes.consolidated_at Revision ID: 0076 Revises: 0075 Create Date: 2026-08-07 Step 1 of milestone #278. Structure only — nothing reads or writes the new table yet, and nothing behaves differently after this runs. ## What the table is for Old records outrank newer ones on the same subject, because a similarity score cannot tell time. A note that accurately described how something worked in June is still accurate ABOUT June; it is just no longer the answer. Nothing recorded that, so nothing could act on it. The claim points FORWARD — the newer record names what it overtakes — because the older one cannot know it has been overtaken. Many-to-many and partial: a note may supersede parts of several others and be overtaken piecemeal by several later ones, which is why this is a table rather than a column. Both directions are queried: `superseded_id` answers "has this been overtaken?" at ranking time, `superseder_id` answers "what does this replace?" in a record view. An array column could serve one and not the other. CASCADE on both sides is safe because trashing is not a delete: `trash_svc` stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings them back. The cascade fires only on `purge_trash`, where the row genuinely goes — and a claim about a row that no longer exists is not actionable. ## What is being dropped, and why now `notes.consolidated_at` was written by NOTHING — no service, no route, no tool — while being serialised into every note and task payload as `null`. It cost a column, a line in every response, and worse: it IMPLIED a capability. A reader reasonably concludes notes can be consolidated and this records when. That reading was reasonable precisely because merge/unmerge exists for snippets and not for notes, so the column looked like the notes-side half of that feature, modelled and abandoned. It is dropped rather than repurposed for supersession, and the distinction is the point (#2483): consolidation folds several records into one survivor and destroys the originals. Merging two snippets is lossless — one helper, several call sites. Folding two dev-logs means writing a summary and losing what each actually said. Supersession is the opposite act: both records survive, and the older one is merely ranked behind. Smuggling one in under a column named for the other would have buried that difference in schema. ## Downgrade Re-adds `consolidated_at` nullable, which is how it lived — so downgrade restores the shape, not the (nonexistent) data. Drops the table; any recorded supersession claims are lost, which costs ranking its input and nothing else, since no note's own content depends on them. """ import sqlalchemy as sa from alembic import op revision = "0076" down_revision = "0075" branch_labels = None depends_on = None def upgrade() -> None: op.create_table( "note_supersessions", sa.Column("id", sa.Integer, primary_key=True), sa.Column( "superseder_id", sa.Integer, sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "superseded_id", sa.Integer, sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False, ), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), sa.UniqueConstraint( "superseder_id", "superseded_id", name="uq_note_supersessions_pair" ), # Declaring that a note supersedes ITSELF is meaningless, and under flat # demotion it would demote a record on its own authority. Refused in the # service too, with a message — this is the backstop that holds when # something writes rows directly. sa.CheckConstraint( "superseder_id <> superseded_id", name="ck_note_supersessions_not_self" ), ) op.create_index( "ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"] ) op.create_index( "ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"] ) op.drop_column("notes", "consolidated_at") def downgrade() -> None: op.add_column( "notes", sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True), ) op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions") op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions") op.drop_table("note_supersessions")