from datetime import datetime from sqlalchemy import BigInteger, ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base from scribe.models.base import CreatedAtMixin, iso class RuleVersion(Base, CreatedAtMixin): """One snapshot of a rule's text, taken before an edit overwrote it. THE SIBLING NOTES ALREADY HAD. `note_versions` has existed for a long time, and the design-system note calls its history "the changelog". Rules — which BIND BEHAVIOUR on every session that loads them — had nothing, so an edit destroyed what the rule used to say. Rescoping rule 79 on 2026-08-29 meant hand-copying the superseded statement into a task log to keep it (#3237). The more consequential record had the weaker protection. `CreatedAtMixin`, not `TimestampMixin`: a version is an EVENT. It is written once and never updated, so an `updated_at` on it would be a column that can only ever lie. WHAT IS DELIBERATELY DIFFERENT FROM NoteVersion (milestone 323): - `user_id` is the ACTOR — who made the edit — where NoteVersion's is the owner, because `update_note` passes an owner-scoped id. For an audit trail over a binding instruction, "who changed this" is the question being asked, and a rule is editable by anyone with rulebook access. - No `pin_kind` / `pin_label`. Those exist so a note's version can survive autosave pruning. Nothing prunes here, so a pin would protect a row that was never at risk. - TEXT ONLY. A rule's Systems and its typed relations are edges with their own lifecycle; folding them in would make one word, "version", mean two different things — the rule's wording, and the rule's place in the graph. `verified_at` is likewise absent: it is a stamp about a check, not a property of the text, and step 2's snapshot is taken before update_rule clears it precisely so the history shows the check that was in force when this wording was written. """ __tablename__ = "rule_versions" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) rule_id: Mapped[int] = mapped_column( BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True ) # The actor. SET NULL rather than CASCADE: deleting a user must not erase # the history of the rules they edited — the edit still happened, and the # rule is still binding because of it. NoteVersion cascades because a # note's versions belong to its owner; a rule's belong to the rule. user_id: Mapped[int | None] = mapped_column( BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) title: Mapped[str] = mapped_column(Text, default="") statement: Mapped[str] = mapped_column(Text, default="") why: Mapped[str | None] = mapped_column(Text, nullable=True) how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True) when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True) tier: Mapped[str | None] = mapped_column(Text, nullable=True) verify_with: Mapped[str | None] = mapped_column(Text, nullable=True) expires_when: Mapped[str | None] = mapped_column(Text, nullable=True) def to_dict(self, include_text: bool = True) -> dict: """The row. `include_text=False` gives the listing form. A rule's `statement` and `why` run to thousands of characters — rule 149's `why` alone is longer than most notes — so a history LIST that carried every field would be unreadable and expensive. The listing answers "when, and by whom"; opening one answers "and what did it say". Same split NoteVersion makes with `include_body`. """ out: dict = { "id": self.id, "rule_id": self.rule_id, "user_id": self.user_id, "title": self.title, "created_at": iso(self.created_at), } if include_text: out.update({ "statement": self.statement, "why": self.why or "", "how_to_apply": self.how_to_apply or "", "when_to_apply": self.when_to_apply or "", "tier": self.tier or "", "verify_with": self.verify_with or "", "expires_when": self.expires_when or "", }) return out