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

`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:
2026-08-29 23:46:15 -04:00
co-authored by Claude Opus 5
parent 255c43a8fe
commit 0704988528
5 changed files with 352 additions and 2 deletions
+95
View File
@@ -196,3 +196,98 @@ async def test_history_reads_newest_first(constraint):
assert [v.statement for v in listed] == [
"first change", "Write every `run:` step in POSIX sh.",
]
# ── The read path is ACL-scoped (milestone 323 step 3) ─────────────────
#
# Rule 47: every read of user data is scoped by owner. A version carries a
# `user_id`, which makes it tempting to scope the history by it — that would
# be wrong in both directions, and these pin which way round it goes.
@pytest_asyncio.fixture
async def stranger():
"""A second user who owns nothing in the fixture above."""
async with async_session() as s:
other = await ensure_user(s, "rule_history_stranger")
uid = other.id
await s.commit()
return uid
async def test_the_owner_reads_the_history(constraint):
await rulebooks_svc.update_rule(
constraint["rule_id"], constraint["uid"], statement="reworded",
)
versions = await rulebooks_svc.list_rule_versions(
constraint["rule_id"], constraint["uid"],
)
assert [v.statement for v in versions] == [
"Write every `run:` step in POSIX sh.",
]
async def test_a_stranger_reads_nothing(constraint, stranger):
"""None, not an empty list. The two mean different things — "not your
rule" versus "this rule has never been reworded" — and collapsing them
would tell a caller the rule exists and is unedited."""
await rulebooks_svc.update_rule(
constraint["rule_id"], constraint["uid"], statement="reworded",
)
assert await rulebooks_svc.list_rule_versions(
constraint["rule_id"], stranger,
) is None
async def test_a_version_cannot_be_read_through_a_DIFFERENT_rule(constraint):
"""The check and the fetch have to agree about which rule is in play.
The second rule is owned by the SAME user on purpose — an id that simply
does not exist would pass on the ownership check alone and prove nothing
about the `rule_id` clause. Here the caller genuinely owns the rule they
name and genuinely owns the version's rule, and the read must still
refuse, because scoping by version id alone is how a snapshot leaks
through whichever rule the caller happens to be able to see.
"""
await rulebooks_svc.update_rule(
constraint["rule_id"], constraint["uid"], statement="reworded",
)
[version] = await _versions(constraint["rule_id"])
async with async_session() as s:
rule = await s.get(Rule, constraint["rule_id"])
topic_id = rule.topic_id
sibling = await rulebooks_svc.create_rule(
topic_id, constraint["uid"], "A different rule", "Unrelated.",
)
assert await rulebooks_svc.get_rule_version(
sibling.id, version.id, constraint["uid"],
) is None
# And through its own rule it reads fine — or the assertion above would
# pass for the wrong reason.
assert await rulebooks_svc.get_rule_version(
constraint["rule_id"], version.id, constraint["uid"],
) is not None
async def test_the_actor_does_not_grant_the_read(constraint, stranger):
"""The inverse mistake. `user_id` on a version is the ACTOR, so scoping
the history 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 somebody else made."""
async with async_session() as s:
s.add(RuleVersion(
rule_id=constraint["rule_id"], user_id=stranger,
title="written by someone else", statement="a stranger's edit",
))
await s.commit()
assert await rulebooks_svc.list_rule_versions(
constraint["rule_id"], stranger,
) is None
owner_view = await rulebooks_svc.list_rule_versions(
constraint["rule_id"], constraint["uid"],
)
assert "a stranger's edit" in [v.statement for v in owner_view]