CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""Real-Postgres tests for the drift surface.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
A preference is the one record kind the AGENT rewrites, mid-work, without
|
|
asking. That is what keeps it current and it is also the risk the milestone
|
|
named up front: an agent misreads one session, rewrites a preference, and
|
|
follows the rewritten version forever while the operator never sees the
|
|
moment it changed — a confident wrong answer wearing the operator's own
|
|
authority.
|
|
|
|
`rule_versions` already recorded every rewrite. What it did not do is arrive.
|
|
`recent_preference_drift` is the read that makes drift PUSHED rather than
|
|
pulled, and `restore_rule_version` is the veto.
|
|
|
|
WHY NOT MOCKS
|
|
|
|
Every claim here is about which rows come back and in what order — a grouped
|
|
subquery picking the newest version per rule, an ownership clause spanning two
|
|
paths, and a write that has to leave the history longer than it found it. A
|
|
stand-in session returns whatever the test handed it, so it could confirm none
|
|
of those. `test_the_restore_is_recorded_as_an_edit` is the one that matters
|
|
most: it is the whole basis for allowing a one-click revert here when rules
|
|
have none, and if it were ever to pass while the
|
|
restore silently overwrote history, the carve-out would be indefensible.
|
|
"""
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import select
|
|
|
|
from grimoire.models import async_session
|
|
from grimoire.models.note import Note
|
|
from grimoire.models.rulebook import Rulebook
|
|
from grimoire.models.rule_version import RuleVersion
|
|
from grimoire.services import rulebooks as rulebooks_svc
|
|
from tests.helpers import ensure_user
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
|
|
|
OWNER_USERNAME = "drift_owner"
|
|
STRANGER_USERNAME = "drift_stranger"
|
|
|
|
# The moment a preference names, in the words a session actually produces.
|
|
# Required on a preference, so every fixture below carries one.
|
|
TRIGGER = "the operator pasted a stack trace and said it is still broken"
|
|
|
|
|
|
async def _purge(uid: int) -> None:
|
|
"""Clear this user's rulebooks. At SETUP, never teardown.
|
|
|
|
`update_rule` fires a detached embedding refresh that opens its own
|
|
connection and UPDATEs the rule row. A teardown delete would race it into
|
|
a deadlock — the same reasoning test_integration_rule_versions records.
|
|
"""
|
|
async with async_session() as s:
|
|
for book in (await s.execute(
|
|
select(Rulebook).where(Rulebook.owner_user_id == uid)
|
|
)).scalars().all():
|
|
await s.delete(book)
|
|
await s.commit()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def world():
|
|
async with async_session() as s:
|
|
owner = await ensure_user(s, OWNER_USERNAME)
|
|
stranger = await ensure_user(s, STRANGER_USERNAME)
|
|
await s.commit()
|
|
uid, sid = owner.id, stranger.id
|
|
|
|
# The record a preference points at as what taught it. A plain note:
|
|
# `arose_from_id` is a FK to notes, and the drift row names it so the
|
|
# operator reads a title rather than a bare number.
|
|
taught = Note(user_id=uid, title="Paced the debugging one step at a time")
|
|
s.add(taught)
|
|
await s.commit()
|
|
await s.refresh(taught)
|
|
taught_id = taught.id
|
|
|
|
await _purge(uid)
|
|
await _purge(sid)
|
|
|
|
book = await rulebooks_svc.create_rulebook(uid, "Drift fixtures")
|
|
topic = await rulebooks_svc.create_topic(book.id, uid, "collaboration")
|
|
|
|
pref = await rulebooks_svc.create_rule(
|
|
topic.id, uid, "Pace hard debugging",
|
|
"Change one thing, then look.",
|
|
when_to_apply=TRIGGER, kind="preference", arose_from_id=taught_id,
|
|
)
|
|
rule = await rulebooks_svc.create_rule(
|
|
topic.id, uid, "dev is home",
|
|
"Ordinary work lands on dev.",
|
|
when_to_apply="about to commit", kind="rule",
|
|
)
|
|
return {
|
|
"uid": uid, "stranger_id": sid, "topic_id": topic.id,
|
|
"pref_id": pref.id, "rule_id": rule.id, "taught_id": taught_id,
|
|
}
|
|
|
|
|
|
async def test_a_preference_nobody_rewrote_is_not_drift(world):
|
|
"""The empty state is a REAL state, not a not-yet-loaded one.
|
|
|
|
A preference that has never been touched has nothing for the operator to
|
|
review, and listing it would bury the rows that do under the ones that
|
|
don't — which is how a supervision surface stops being read.
|
|
"""
|
|
rows = await rulebooks_svc.recent_preference_drift(world["uid"])
|
|
assert rows == []
|
|
|
|
|
|
async def test_a_rewritten_preference_carries_both_wordings_and_what_taught_it(world):
|
|
await rulebooks_svc.update_rule(
|
|
world["pref_id"], world["uid"],
|
|
statement="Change one thing, then look. Say what you expect first.",
|
|
)
|
|
|
|
[row] = await rulebooks_svc.recent_preference_drift(world["uid"])
|
|
assert row["rule"]["id"] == world["pref_id"]
|
|
# Both texts ride along, so the list can show the diff without a
|
|
# follow-up call per row.
|
|
assert row["previous"]["statement"] == "Change one thing, then look."
|
|
assert row["current"]["statement"].endswith("Say what you expect first.")
|
|
# The provenance is NAMED, not numbered: a bare id reads as complete to
|
|
# the writer and as homework to the reader.
|
|
assert row["taught_by"] == {
|
|
"id": world["taught_id"],
|
|
"title": "Paced the debugging one step at a time",
|
|
}
|
|
|
|
|
|
async def test_a_rewritten_rule_is_not_listed(world):
|
|
"""Not a filter that could be relaxed — the question is what changed
|
|
WITHOUT the operator, and a rule changes when they change it. Including
|
|
rules would bury the few unreviewed rows under every edit they made."""
|
|
await rulebooks_svc.update_rule(
|
|
world["rule_id"], world["uid"], statement="Ordinary work lands on dev, always.",
|
|
)
|
|
assert await rulebooks_svc.recent_preference_drift(world["uid"]) == []
|
|
|
|
|
|
async def test_one_row_per_preference_carrying_its_LATEST_rewrite(world):
|
|
"""A preference rewritten three times this week is one answer to "what
|
|
changed lately", not three. The full history stays in its editor."""
|
|
for text in ("second wording.", "third wording.", "fourth wording."):
|
|
await rulebooks_svc.update_rule(
|
|
world["pref_id"], world["uid"], statement=text,
|
|
)
|
|
|
|
rows = await rulebooks_svc.recent_preference_drift(world["uid"])
|
|
assert len(rows) == 1
|
|
# The newest version holds what the LAST edit replaced.
|
|
assert rows[0]["previous"]["statement"] == "third wording."
|
|
assert rows[0]["current"]["statement"] == "fourth wording."
|
|
|
|
|
|
async def test_most_recently_changed_first(world):
|
|
"""The ORDER is the answer: the top of this list is what changed last."""
|
|
second = await rulebooks_svc.create_rule(
|
|
world["topic_id"], world["uid"], "Name the record",
|
|
"Say the title beside the id.",
|
|
when_to_apply="about to cite a record by number", kind="preference",
|
|
)
|
|
await rulebooks_svc.update_rule(
|
|
world["pref_id"], world["uid"], statement="edited first.",
|
|
)
|
|
await rulebooks_svc.update_rule(
|
|
second.id, world["uid"], statement="edited second.",
|
|
)
|
|
|
|
rows = await rulebooks_svc.recent_preference_drift(world["uid"])
|
|
assert [r["rule"]["id"] for r in rows] == [second.id, world["pref_id"]]
|
|
|
|
|
|
async def test_another_users_preferences_are_not_listed(world):
|
|
"""The listing clause has to agree with the per-row fetch. A drift pane
|
|
that reached across owners would leak the wording of someone else's
|
|
preference — and the wording is the whole payload."""
|
|
await rulebooks_svc.update_rule(
|
|
world["pref_id"], world["uid"], statement="mine, rewritten.",
|
|
)
|
|
assert await rulebooks_svc.recent_preference_drift(world["stranger_id"]) == []
|
|
|
|
|
|
async def test_the_limit_bounds_the_list_and_keeps_the_newest(world):
|
|
"""A cap that dropped the wrong end would hide the change just made.
|
|
|
|
Bounded because the payload is two full statements per row — so the list
|
|
has to stay short — and the end it keeps has to be the recent one, which
|
|
is the only end this surface is about.
|
|
"""
|
|
second = await rulebooks_svc.create_rule(
|
|
world["topic_id"], world["uid"], "Name the record",
|
|
"Say the title beside the id.",
|
|
when_to_apply="about to cite a record by number", kind="preference",
|
|
)
|
|
await rulebooks_svc.update_rule(
|
|
world["pref_id"], world["uid"], statement="edited first.",
|
|
)
|
|
await rulebooks_svc.update_rule(
|
|
second.id, world["uid"], statement="edited second.",
|
|
)
|
|
|
|
rows = await rulebooks_svc.recent_preference_drift(world["uid"], limit=1)
|
|
assert [r["rule"]["id"] for r in rows] == [second.id]
|
|
# A caller asking for everything gets a page, not the table — and the
|
|
# clamp is silent rather than an error, because "too many" is a request
|
|
# to serve, not a mistake to report.
|
|
assert len(await rulebooks_svc.recent_preference_drift(
|
|
world["uid"], limit=10_000,
|
|
)) == 2
|
|
|
|
|
|
async def test_restoring_puts_the_old_wording_back(world):
|
|
await rulebooks_svc.update_rule(
|
|
world["pref_id"], world["uid"], statement="the agent's rewrite.",
|
|
)
|
|
[row] = await rulebooks_svc.recent_preference_drift(world["uid"])
|
|
|
|
restored = await rulebooks_svc.restore_rule_version(
|
|
world["pref_id"], row["previous"]["id"], world["uid"],
|
|
)
|
|
assert restored is not None
|
|
assert restored.statement == "Change one thing, then look."
|
|
|
|
|
|
async def test_the_restore_is_recorded_as_an_edit(world):
|
|
"""THE ONE THAT MATTERS MOST — it is why this carve-out is defensible.
|
|
|
|
Rules have no one-click restore because a silent revert would erase the
|
|
only record of why the rewrite happened. The
|
|
preference carve-out survives that objection only while nothing is
|
|
erased: the restore goes through `update_rule`, so it takes its own
|
|
snapshot and the history GAINS an entry.
|
|
|
|
A restore implemented as a direct write would satisfy the test above and
|
|
fail this one, which is the point of having both.
|
|
"""
|
|
await rulebooks_svc.update_rule(
|
|
world["pref_id"], world["uid"], statement="the agent's rewrite.",
|
|
)
|
|
async with async_session() as s:
|
|
before = len((await s.execute(
|
|
select(RuleVersion).where(RuleVersion.rule_id == world["pref_id"])
|
|
)).scalars().all())
|
|
|
|
[row] = await rulebooks_svc.recent_preference_drift(world["uid"])
|
|
await rulebooks_svc.restore_rule_version(
|
|
world["pref_id"], row["previous"]["id"], world["uid"],
|
|
)
|
|
|
|
async with async_session() as s:
|
|
versions = list((await s.execute(
|
|
select(RuleVersion).where(RuleVersion.rule_id == world["pref_id"])
|
|
.order_by(RuleVersion.id)
|
|
)).scalars().all())
|
|
assert len(versions) == before + 1
|
|
# The rewrite is still there, and the newest entry is what the restore
|
|
# replaced — the rewrite itself. Nothing was overwritten.
|
|
assert versions[-1].statement == "the agent's rewrite."
|
|
|
|
|
|
async def test_a_rule_refuses_the_one_click_restore(world):
|
|
"""Rules still have no restore, and the kind check is in the service
|
|
rather than only in the route — so a caller reaching this directly cannot
|
|
forget it."""
|
|
await rulebooks_svc.update_rule(
|
|
world["rule_id"], world["uid"], statement="rewritten by hand.",
|
|
)
|
|
async with async_session() as s:
|
|
version = (await s.execute(
|
|
select(RuleVersion).where(RuleVersion.rule_id == world["rule_id"])
|
|
)).scalars().first()
|
|
|
|
with pytest.raises(ValueError, match="preference"):
|
|
await rulebooks_svc.restore_rule_version(
|
|
world["rule_id"], version.id, world["uid"],
|
|
)
|
|
|
|
|
|
async def test_a_stranger_cannot_restore_a_preference_they_cannot_read(world):
|
|
"""None, not a raise: "not yours" and "not a preference" are different
|
|
answers, and the route turns them into 404 and 409 respectively."""
|
|
await rulebooks_svc.update_rule(
|
|
world["pref_id"], world["uid"], statement="mine, rewritten.",
|
|
)
|
|
[row] = await rulebooks_svc.recent_preference_drift(world["uid"])
|
|
assert await rulebooks_svc.restore_rule_version(
|
|
world["pref_id"], row["previous"]["id"], world["stranger_id"],
|
|
) is None
|