CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Failing after 31s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 23s
The sibling notes already had. `note_versions` snapshots a note's every meaningful edit; a RULE, which binds behaviour on every session that loads it, had nothing — an edit destroyed the previous wording with no record anywhere. Rescoping rule 79 meant hand-copying the superseded statement into a task log to keep it (#3237). The more consequential record had the weaker protection. Schema and transport only. Nothing writes a version yet — that is step 2. Three guards are deliberately NOT copied from note_versions, each defending against autosave, which rules do not have: no pruning or MAX_VERSIONS, no pin columns, no minimum interval. A rule is edited a handful of times in its life, and capping invites losing the one edit somebody needed. `user_id` is the ACTOR rather than the owner, and SET NULL rather than CASCADE: deleting a user must not erase the history of the rules they edited. The restore diverges from its NoteVersion sibling accordingly — an unmappable user leaves the row with a null actor instead of dropping it, which is the whole point of choosing SET NULL. The integration round trip pins that, because nothing in the code says which of the two shapes is intended and "make it match the sibling" would silently delete the record. Backup goes to v13. Both export paths carry the table; the per-user one scopes through the rule rather than the version's user_id, or it would carry the versions this user wrote on someone else's rule and drop the ones someone else wrote on theirs. The restore remaps rule_id through rule_id_map — #3182's arose_from_id trap on a new table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
4.3 KiB
Python
92 lines
4.3 KiB
Python
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
|