Supersession (steps 1–4) — corrections demote, state lives on Systems #101

Merged
bvandeusen merged 8 commits from dev into main 2026-08-08 18:25:30 -04:00
8 changed files with 196 additions and 58 deletions
Showing only changes of commit 45c6b1c88a - Show all commits
+115
View File
@@ -0,0 +1,115 @@
"""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")
-1
View File
@@ -10,7 +10,6 @@ export interface Note {
title: string; title: string;
body: string; body: string;
description: string | null; description: string | null;
consolidated_at: string | null;
tags: string[]; tags: string[];
parent_id: number | null; parent_id: number | null;
parent_title?: string | null; parent_title?: string | null;
-20
View File
@@ -41,7 +41,6 @@ const toast = useToastStore();
const title = ref(""); const title = ref("");
const body = ref(""); const body = ref("");
const description = ref(""); const description = ref("");
const consolidatedAt = ref<string | null>(null);
const tags = ref<string[]>([]); const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo"); const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none"); const priority = ref<TaskPriority>("none");
@@ -303,7 +302,6 @@ onMounted(async () => {
title.value = store.currentTask.title; title.value = store.currentTask.title;
body.value = store.currentTask.body; body.value = store.currentTask.body;
description.value = store.currentTask.description ?? ""; description.value = store.currentTask.description ?? "";
consolidatedAt.value = store.currentTask.consolidated_at ?? null;
tags.value = [...(store.currentTask.tags || [])]; tags.value = [...(store.currentTask.tags || [])];
status.value = store.currentTask.status as TaskStatus; status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority; priority.value = store.currentTask.priority as TaskPriority;
@@ -1063,22 +1061,4 @@ useEditorGuards(dirty, save);
border-color: var(--color-primary); border-color: var(--color-primary);
} }
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
.auto-summary-banner-editor {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.7rem;
margin-bottom: 0.5rem;
font-size: 0.82rem;
font-style: italic;
color: var(--color-text-muted);
background: rgba(99, 102, 241, 0.06);
border-left: 2px solid var(--color-primary);
border-radius: var(--radius-sm);
}
.auto-summary-banner-editor .auto-summary-icon {
color: var(--color-primary);
font-style: normal;
}
</style> </style>
-20
View File
@@ -365,13 +365,6 @@ const subTaskProgress = computed(() => {
<p class="goal-text">{{ store.currentTask.description }}</p> <p class="goal-text">{{ store.currentTask.description }}</p>
</div> </div>
<div
v-if="store.currentTask.consolidated_at"
class="auto-summary-banner"
>
<span class="auto-summary-icon" aria-hidden="true"></span>
Auto-summarized from work logs.
</div>
<div <div
class="body prose" class="body prose"
@@ -771,17 +764,4 @@ const subTaskProgress = computed(() => {
color: var(--color-text); color: var(--color-text);
white-space: pre-wrap; white-space: pre-wrap;
} }
.auto-summary-banner {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.78rem;
font-style: italic;
color: var(--color-text-muted);
margin: 0 0 0.75rem;
}
.auto-summary-icon {
color: var(--color-primary);
font-size: 0.85rem;
}
</style> </style>
+1
View File
@@ -33,6 +33,7 @@ from scribe.models.milestone import Milestone # noqa: E402, F401
from scribe.models.task_log import TaskLog # noqa: E402, F401 from scribe.models.task_log import TaskLog # noqa: E402, F401
from scribe.models.note_draft import NoteDraft # noqa: E402, F401 from scribe.models.note_draft import NoteDraft # noqa: E402, F401
from scribe.models.note_version import NoteVersion # noqa: E402, F401 from scribe.models.note_version import NoteVersion # noqa: E402, F401
from scribe.models.note_supersession import NoteSupersession # noqa: E402, F401
from scribe.models.group import Group, GroupMembership # noqa: E402, F401 from scribe.models.group import Group, GroupMembership # noqa: E402, F401
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401 from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
from scribe.models.notification import Notification # noqa: E402, F401 from scribe.models.notification import Notification # noqa: E402, F401
-6
View File
@@ -33,9 +33,6 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
title: Mapped[str] = mapped_column(Text, default="") title: Mapped[str] = mapped_column(Text, default="")
body: Mapped[str] = mapped_column(Text, default="") body: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
consolidated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
@@ -101,9 +98,6 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
"title": self.title, "title": self.title,
"body": self.body, "body": self.body,
"description": self.description, "description": self.description,
"consolidated_at": (
self.consolidated_at.isoformat() if self.consolidated_at else None
),
"tags": self.tags or [], "tags": self.tags or [],
"parent_id": self.parent_id, "parent_id": self.parent_id,
"arose_from_id": self.arose_from_id, "arose_from_id": self.arose_from_id,
+70
View File
@@ -0,0 +1,70 @@
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class NoteSupersession(Base, CreatedAtMixin):
"""A newer record's claim that it has overtaken an older one.
WHY THE RELATION POINTS FORWARD
The note being WRITTEN declares what it supersedes. The old record cannot
know it has been overtaken — asking it to record its own obsolescence is
asking it to predict the future. So the claim is made by the party that has
the knowledge, and the demotion is derived from the far end.
WHY A TABLE RATHER THAN A COLUMN
It is genuinely many-to-many and partial: one note may supersede parts of
several others, and a note may be overtaken piecemeal by several later ones.
Both directions are queried and neither is rare —
`superseded_id` answers the ranking question ("has this been overtaken?"),
`superseder_id` answers the record view ("what does this replace?"). An
array column on `notes` could be indexed for one and not the other.
WHAT IT MEANS, AND WHAT IT DOES NOT
A claim, never a proof. Supersession DEMOTES a record in ranked retrieval;
it does not assert the older record was wrong, and it never hides it. A note
that accurately described how something worked in June is still accurate
about June — it is just no longer the answer to "how does this work".
CASCADE IS SAFE HERE BECAUSE TRASHING IS NOT A DELETE
`trash_svc` stamps `deleted_at` (an UPDATE), so a trashed note keeps its
claims and `restore` brings them back intact. The cascade fires only on
`purge_trash`, where the row genuinely goes — and a supersession claim about
a row that no longer exists is not a fact anyone can act on.
"""
__tablename__ = "note_supersessions"
id: Mapped[int] = mapped_column(primary_key=True)
# The newer record, making the claim.
superseder_id: Mapped[int] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="CASCADE")
)
# The older record, demoted by it.
superseded_id: Mapped[int] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="CASCADE")
)
__table_args__ = (
UniqueConstraint(
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
),
# Both directions indexed — see the class docstring for why neither is
# the rare one.
Index("ix_note_supersessions_superseder", "superseder_id"),
Index("ix_note_supersessions_superseded", "superseded_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"superseder_id": self.superseder_id,
"superseded_id": self.superseded_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
+10 -11
View File
@@ -373,17 +373,16 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
return note return note
async def delete_note(user_id: int, note_id: int) -> bool: # A hard `delete_note(user_id, note_id)` lived here with ZERO callers, and was
async with async_session() as session: # removed with #278 step 1. It is recorded rather than silently dropped because
result = await session.execute( # the danger was never that it ran — it is that it was findable by name. Someone
select(Note).where(Note.id == note_id, Note.user_id == user_id) # wanting to delete a note greps `delete_note`, finds a function in the notes
) # service with exactly the right signature, and permanently destroys a record
note = result.scalars().first() # every path downstream expects to be recoverable.
if note is None: #
return False # The delete path is `trash_svc.delete`, which soft-deletes an entity AND its
await session.delete(note) # descendants under one batch_id so `restore(batch)` works. `purge_trash` owns
await session.commit() # permanent deletion. Both are reachable; neither is spelled `delete_note`.
return True
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]: async def get_all_tags(user_id: int, q: str | None = None) -> list[str]: