"""A rule's edit history — the snapshot taken when an edit overwrites text. Sibling of `note_versions`, and the differences are the whole design. WHAT A VERSION IS FOR. It records what a rule USED TO SAY, because an update overwrites that and nothing can recompute it. It is not a record of the rule's lifecycle: a soft delete keeps the rule row whole and restorable, so there is nothing to preserve at delete time, and anything that outlived a purge would be data the operator explicitly asked to be gone. WHAT IS DELIBERATELY NOT COPIED FROM note_versions (milestone 323). Both of its guards defend against note autosave, which fires every 60 seconds and would otherwise burn every slot. Rules have no autosave — every version here comes from a deliberate `update_rule` — so both guards would only ever discard a real edit: - No `MIN_VERSION_INTERVAL_SECONDS`. Two deliberate edits four minutes apart are two edits. - No `MAX_VERSIONS` and no pruning. A rule is edited a handful of times in its life; a cap invites losing the one edit somebody needed. TEXT ONLY. A rule's topic, its Systems tags and its typed edges are not here. They have their own lifecycle, and folding them in would make one word, "version", mean two things — the rule's wording, and the rule's place in the graph. """ from sqlalchemy import select from scribe.models import async_session from scribe.models.rule_version import RuleVersion # The columns a version carries, and therefore the ones whose change is worth # a snapshot. `order_index` and `arose_from_id` are editable through # update_rule and are deliberately absent: reordering a rulebook is not an # edit to what any rule says, and a run of "moved rule 4 above rule 3" # snapshots would bury the edits somebody is actually looking for. SNAPSHOT_FIELDS = ( "title", "statement", "why", "how_to_apply", "when_to_apply", "tier", "kind", "verify_with", "expires_when", ) def snapshot(rule) -> dict: """The rule's text right now, as a plain dict. Plain values rather than the ORM object because the caller has to hold this ACROSS the mutation — a live `rule` would show the new text by the time it was read, which is the one thing the snapshot must not do. """ return {f: getattr(rule, f) for f in SNAPSHOT_FIELDS} def record_if_changed(session, rule, actor_user_id: int | None, before: dict): """Add a version holding `before`, unless the edit changed no text. Git semantics, and not a nicety: `update_rule` is reached by callers that resend every field, so without this a form saved twice would file a second identical snapshot and the history would stop reading as a list of edits. Session-BOUND, unlike `note_versions.create_version` which opens its own. The version and the edit that caused it commit together or not at all; a separate session could leave a snapshot behind after the update it describes had failed, which is a history entry for an edit that never happened. """ if snapshot(rule) == before: return None version = RuleVersion( rule_id=rule.id, user_id=actor_user_id, **before, ) session.add(version) return version async def list_versions(rule_id: int) -> list[RuleVersion]: """Newest first — the reader's question is "what did this just say?".""" async with async_session() as session: return list((await session.execute( select(RuleVersion) .where(RuleVersion.rule_id == rule_id) .order_by(RuleVersion.created_at.desc(), RuleVersion.id.desc()) )).scalars().all())