CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m11s
CI & Build / Build & push image (push) Skipped
Adds `kind` to rules — `rule` binds, `preference` is how the operator wants work done. One column, because the two differ in exactly one dimension and everything else a preference needs already lives on `rules`: a trigger column, a trigger-dominated embedding document, ownership-scoped search, three retrieval arms with telemetry, typed relations, and versioning. Defaults to `rule`, so nothing changes force on upgrade — 0088's argument for `tier`, unchanged. `rule_versions` gets the column too, and that half is not bookkeeping. `record_if_changed` decides whether an edit deserves a snapshot by comparing the fields a version carries, so a field absent from SNAPSHOT_FIELDS is a field whose change records no history at all. Without it, turning a rule into a preference — the moment something stops binding, and the single most consequential edit either kind can undergo — would leave the history silent. Backup carries it through all four seams. A missed one would have restored every preference as a rule, quietly. Guarded on real Postgres in three halves: a preference writes, a typo is refused (without which every other assertion would pass against a table whose CHECK had been dropped), and a row written with no kind reads back as `rule` — the migration's whole safety claim, asserted rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
83 lines
3.5 KiB
Python
83 lines
3.5 KiB
Python
"""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())
|