Files
FabledScribe/src/scribe/models/note_supersession.py
T
bvandeusenandClaude Fable 5 b0eda32575
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
refactor(models): one iso() for every to_dict timestamp; mixins replace hand-rolled created_at/updated_at (#2827, milestone 296 area 3)
Reading all 28 models against each other: 54 `x.isoformat() if x else None` /
`x.isoformat()` expressions in 23 to_dict methods, in two guarded/unguarded
wordings, become iso() from models/base.py — uniform, and a row read before
flush serialises as null instead of raising. Rulebook / RulebookTopic / Rule
carried byte-identical copies of TimestampMixin's two columns;
InvitationToken / PasswordResetToken / NoteUsageEvent carried CreatedAtMixin's —
all six now use the mixin. AppLog and RetrievalLog keep their explicit
created_at, commented: their composite index orders on `created_at.desc()`,
which needs the column object in the class body. Schema-neutral (same column
definitions) — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:17:42 -04:00

71 lines
2.8 KiB
Python

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, iso
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": iso(self.created_at),
}