feat(rules): the history is readable — service, REST and MCP (#3242, milestone 323 step 3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 30s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 14s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 30s
`list_rule_versions` / `get_rule_version` in the rulebooks service, a pair of REST routes beside the note-version ones, and an MCP `rule_history` tool. The ACL-scoped reads live in services/rulebooks.py rather than in services/rule_versions.py because rulebooks already imports rule_versions for the write path and the reverse would be a cycle. It is also the honest split: rule_versions owns what a version IS, rulebooks owns who may read one. Scoping is through the RULE, never the version's user_id, and both directions of that mistake are now pinned by tests. That column is the ACTOR — scoping by it would hand someone the snapshots they personally wrote on a rule that has since moved out of their reach, and would hide from the rule's owner every edit anyone else made. `get_rule_version` takes the rule id as well as the version id so the ownership check and the fetch agree about which rule is in play; the test for that uses a second rule the caller genuinely owns, because a nonexistent id would pass on the ownership check alone and prove nothing. An unreadable rule returns None, not an empty list. The two mean different things — "not your rule" versus "never reworded" — and the MCP tool keeps them apart: None raises, empty says so in band. THE DIFF QUESTION, ANSWERED — and the task's premise was half wrong. It says "notes have DiffView.vue and a diff endpoint already". The component exists and is reusable as-is: it takes `DiffLine[]` and nothing note-shaped, so step 4 can render a rule diff with it unchanged. The ENDPOINT does not exist — diffs are computed client-side by `computeDiff` in useAssist.ts. So no diff route is needed here, and none was written. For the MCP door the answer is different again: an agent has no client to compute a diff, but it also does not need one. Each entry holds the text the edit REPLACED, so "what did this say before the most recent change?" is the first entry, and the text that change produced is the rule as it stands. The docstring says so, and a test pins that sentence — read the other way round, every diff comes out backwards. No restore, per the task. Putting an old wording back goes through update_rule, which snapshots what it replaces, so the undo stays visible like any other edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ from scribe.services.verification import (
|
||||
last_verified_label as _last_verified_label,
|
||||
)
|
||||
from scribe.services import rule_versions
|
||||
from scribe.models.rule_version import RuleVersion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -787,6 +788,48 @@ async def update_rule(
|
||||
return rule
|
||||
|
||||
|
||||
# ── Edit history (milestone 323) ───────────────────────────────────────
|
||||
#
|
||||
# The ACL-scoped reads live HERE rather than in services/rule_versions.py,
|
||||
# and not by preference: rulebooks imports rule_versions for the write path,
|
||||
# so the reverse import would be a cycle. The split is also the honest one —
|
||||
# rule_versions owns what a version IS, this module owns who may read one.
|
||||
|
||||
|
||||
async def list_rule_versions(rule_id: int, user_id: int):
|
||||
"""A rule's history, newest first. None when the rule is not readable.
|
||||
|
||||
Scoped through the rule itself, never through the version's `user_id`:
|
||||
that column is the ACTOR. Reading a rule's history is a question about
|
||||
the RULE, so anyone who can read the rule can read what it used to say,
|
||||
and anyone who cannot read the rule gets nothing — including the versions
|
||||
they personally wrote, if the rule has since moved out of their reach.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
if await _fetch_owned_rule(session, rule_id, user_id) is None:
|
||||
return None
|
||||
return await rule_versions.list_versions(rule_id)
|
||||
|
||||
|
||||
async def get_rule_version(rule_id: int, version_id: int, user_id: int):
|
||||
"""One snapshot in full. None when the rule or the version is not found.
|
||||
|
||||
Takes the rule id as well as the version id so the ownership check has
|
||||
something to run against BEFORE the version is read, and so a version id
|
||||
from another rule cannot be read through a rule the caller does happen to
|
||||
own — the check and the fetch have to agree about which rule is in play.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
if await _fetch_owned_rule(session, rule_id, user_id) is None:
|
||||
return None
|
||||
return (await session.execute(
|
||||
select(RuleVersion).where(
|
||||
RuleVersion.id == version_id,
|
||||
RuleVersion.rule_id == rule_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
|
||||
# ── Canon tags + typed edges (milestone 307) ───────────────────────────
|
||||
|
||||
async def set_rule_systems(
|
||||
|
||||
Reference in New Issue
Block a user