Files
FabledScribe/src/scribe/models/note.py
T
bvandeusen 45c6b1c88a
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Failing after 32s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 25s
feat(supersession): the relation, and the dead column that stood where it should
Step 1 of #278. Structure only — nothing reads or writes the new table yet.

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.

`note_supersessions(superseder_id, superseded_id)`. The claim points FORWARD —
the newer record names what it overtakes — because the older one cannot know it
has been overtaken; asking it to record its own obsolescence is asking it to
predict the future.

A table rather than a column because the relation is genuinely many-to-many and
partial, and both directions are hot: superseded_id answers "has this been
overtaken?" at ranking time, superseder_id answers "what does this replace?" in
a record view. An array column serves one and not the other.

CASCADE 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. It fires only
on purge_trash, where a claim about the row would be unactionable anyway. A
CHECK rejects self-supersession, which under flat demotion would let a record
demote itself.

## consolidated_at, and what it actually was

Dropped. Written by nothing while serialised into every note and task payload
as null — and worse, it implied a capability.

The survey (#2483) read it as note consolidation modelled and abandoned. That
was wrong, and the frontend is what says so: `TaskViewerView` rendered
"✦ Auto-summarized from work logs" gated on this column. It is a survivor of
the pre-pivot auto-summary subsystem (migration 0030), whose own column #599
removed. Not an unbuilt feature — an outlived one.

So four more remnants went with it: the banner, its CSS, a `consolidatedAt` ref
in TaskEditorView assigned and never read, and `.auto-summary-banner-editor`
styling with zero template usage. That last one is presence-without-reference
in the same family as the column itself.

Dropped rather than repurposed for supersession, and the distinction is the
point: consolidation folds records into one survivor and destroys the
originals. Supersession is the opposite — both survive, the older ranks behind.
Smuggling one in under a column named for the other would bury that in schema.

## The hard delete_note

Removed, with a comment where it stood. Zero callers, and the danger was never
that it ran — it is that it was findable by name. Someone 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 every path downstream
expects to be recoverable. The MCP tool of the same name already went through
trash_svc; only the service function was the trap.

Refs #278, #2483
2026-08-07 21:40:51 -04:00

123 lines
5.4 KiB
Python

import enum
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin
class TaskStatus(str, enum.Enum):
todo = "todo"
in_progress = "in_progress"
done = "done"
cancelled = "cancelled"
class TaskPriority(str, enum.Enum):
none = "none"
low = "low"
medium = "medium"
high = "high"
class Note(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
title: Mapped[str] = mapped_column(Text, default="")
body: Mapped[str] = mapped_column(Text, default="")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
parent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
# Provenance: the task/feature an issue arose from. Distinct from parent_id
# (sub-task hierarchy) — this is "what spawned this". Only meaningful for
# issues; nullable for every record.
arose_from_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
project_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
)
milestone_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str | None] = mapped_column(Text, nullable=True)
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Note type — 'note' (default) or 'process' (a stored process). Task-ness is
# tracked by `status`, not here. (person/place/list entity types removed 2026-07.)
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
# Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work).
# Only meaningful when the note is a task (status is not None); ordinary
# notes keep the 'work' default and ignore it. Orthogonal to note_type
# (which is the note/entity axis).
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
# Queryable structured fields for typed records — currently snippets, whose
# name/language/signature/locations live here so they can be INDEXED. The
# body keeps the same facts in readable markdown and remains what gets
# embedded; this is a mirror for querying, not the source of truth for
# display. NULL on every row written before migration 0070, so readers fall
# back to parsing the body (see services/snippets.snippet_fields).
data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
Index("ix_notes_status", "status"),
Index("ix_notes_title", "title"),
Index("ix_notes_user_id", "user_id"),
Index("ix_notes_project_id", "project_id"),
Index("ix_notes_milestone_id", "milestone_id"),
Index("ix_notes_note_type", "note_type"),
Index("ix_notes_arose_from_id", "arose_from_id"),
# Containment queries into `data` — e.g. which snippets name a given
# repo/path in their locations. See migration 0070.
Index("ix_notes_data_gin", "data", postgresql_using="gin"),
)
@property
def is_task(self) -> bool:
return self.status is not None
def to_dict(self) -> dict:
return {
"id": self.id,
"title": self.title,
"body": self.body,
"description": self.description,
"tags": self.tags or [],
"parent_id": self.parent_id,
"arose_from_id": self.arose_from_id,
"project_id": self.project_id,
"milestone_id": self.milestone_id,
"status": self.status,
"priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"recurrence_rule": self.recurrence_rule,
"recurrence_next_spawn_at": (
self.recurrence_next_spawn_at.isoformat()
if self.recurrence_next_spawn_at
else None
),
"is_task": self.is_task,
"note_type": self.note_type or "note",
"task_kind": self.task_kind,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}