CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""Real-Postgres tests for a rule's CHECK — the write half (milestone 312).
|
|
|
|
What mocks cannot prove, and what the staleness sweep depends on:
|
|
|
|
1. **Empty means NULL.** The sweep asks for rules where `verify_with` is set.
|
|
A write that stored "" would leave a column that is neither null nor
|
|
content, and every rule ever touched through the REST door would answer
|
|
"yes, I have a check" — the sweep would list the whole rulebook and mean
|
|
nothing. Only a real column can show the difference; `to_dict`'s `or ""`
|
|
renders both the same.
|
|
|
|
2. **Clearing is possible at all.** "" means "leave unchanged" at the MCP
|
|
door, so without an explicit clear there is no way to retire a check.
|
|
|
|
3. **A stamp does not outlive the check it certifies.** Reword the check and
|
|
the old `verified_at` vouches for something that no longer exists.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.rulebook import Rule
|
|
from scribe.services import rulebooks as rulebooks_svc
|
|
from tests.helpers import ensure_user
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def constraint():
|
|
"""One rule carrying a check, already verified.
|
|
|
|
Verified at creation time rather than left null, because every assertion
|
|
here is about what happens to an EXISTING stamp — a fixture that started
|
|
null could pass all of them by doing nothing.
|
|
"""
|
|
async with async_session() as s:
|
|
owner = await ensure_user(s, "verification_owner")
|
|
uid = owner.id
|
|
await s.commit()
|
|
|
|
book = await rulebooks_svc.create_rulebook(uid, "Environment facts")
|
|
topic = await rulebooks_svc.create_topic(book.id, uid, "ci")
|
|
rule = await rulebooks_svc.create_rule(
|
|
topic.id, uid, "The runner has no bash",
|
|
"Write every `run:` step in POSIX sh.",
|
|
verify_with="read the workflow's shell setting",
|
|
expires_when="the runner can be given a bash shell",
|
|
)
|
|
async with async_session() as s:
|
|
row = await s.get(Rule, rule.id)
|
|
row.verified_at = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
|
await s.commit()
|
|
return {"uid": uid, "rule": rule.id}
|
|
|
|
|
|
async def _row(rule_id: int) -> Rule:
|
|
async with async_session() as s:
|
|
return await s.get(Rule, rule_id)
|
|
|
|
|
|
async def test_the_check_and_its_expiry_persist(constraint):
|
|
row = await _row(constraint["rule"])
|
|
assert row.verify_with == "read the workflow's shell setting"
|
|
assert row.expires_when == "the runner can be given a bash shell"
|
|
assert row.verified_at is not None
|
|
|
|
|
|
async def test_an_empty_string_becomes_null_not_an_empty_column(constraint):
|
|
"""The REST door's idiom: a cleared form input arrives as "".
|
|
|
|
NULL is asserted directly against the column rather than through to_dict,
|
|
which renders `None` and `""` identically — the difference this test
|
|
exists for would be invisible one layer up.
|
|
"""
|
|
await rulebooks_svc.update_rule(
|
|
constraint["rule"], constraint["uid"], verify_with="", expires_when="",
|
|
)
|
|
row = await _row(constraint["rule"])
|
|
assert row.verify_with is None
|
|
assert row.expires_when is None
|
|
|
|
|
|
async def test_naming_a_field_in_clear_empties_it(constraint):
|
|
"""The MCP door's idiom, where "" already means "leave this alone"."""
|
|
await rulebooks_svc.update_rule(
|
|
constraint["rule"], constraint["uid"], clear=["verify_with"],
|
|
)
|
|
row = await _row(constraint["rule"])
|
|
assert row.verify_with is None
|
|
# expires_when was NOT named, so it survives — clearing is per-field, and
|
|
# a caller retiring one field must not lose the others.
|
|
assert row.expires_when == "the runner can be given a bash shell"
|
|
|
|
|
|
async def test_rewording_the_check_drops_the_stamp(constraint):
|
|
"""A stamp certifies a check, not a rule.
|
|
|
|
The safe direction, for the same reason _valid_tier falls back to
|
|
the safe direction: a rule wrongly listed as due costs one look, a rule wrongly
|
|
vouched for costs exactly what the sweep exists to catch.
|
|
"""
|
|
await rulebooks_svc.update_rule(
|
|
constraint["rule"], constraint["uid"],
|
|
verify_with="read the runner's container shell, not the image's",
|
|
)
|
|
row = await _row(constraint["rule"])
|
|
assert row.verified_at is None
|
|
|
|
|
|
async def test_clearing_the_check_drops_the_stamp(constraint):
|
|
await rulebooks_svc.update_rule(
|
|
constraint["rule"], constraint["uid"], clear=["verify_with"],
|
|
)
|
|
row = await _row(constraint["rule"])
|
|
assert row.verified_at is None
|
|
|
|
|
|
async def test_editing_anything_else_leaves_the_stamp_alone(constraint):
|
|
"""The other half of the rule above, and the one that keeps it useful.
|
|
|
|
If any edit reset the stamp, a rulebook tidy-up would put every constraint
|
|
back at the top of the sweep and the ordering would carry no information.
|
|
Only the check's own text invalidates its verification.
|
|
"""
|
|
await rulebooks_svc.update_rule(
|
|
constraint["rule"], constraint["uid"],
|
|
why="act_runner picks the shell, and the image's SHELL directive "
|
|
"applies to the build, not to `run:`.",
|
|
expires_when="the runner grows a shell setting",
|
|
)
|
|
row = await _row(constraint["rule"])
|
|
assert row.verified_at is not None
|
|
assert row.why.startswith("act_runner picks the shell")
|
|
|
|
|
|
# ── the sweep itself (step 3) ──────────────────────────────────────────
|
|
|
|
@pytest_asyncio.fixture
|
|
async def rulebook_of_three():
|
|
"""A decision, a never-checked constraint, and a long-ago-checked one.
|
|
|
|
Three rows because the sweep's whole value is an ORDER, and an order
|
|
cannot be asserted with fewer.
|
|
"""
|
|
async with async_session() as s:
|
|
owner = await ensure_user(s, "sweep_owner")
|
|
uid = owner.id
|
|
await s.commit()
|
|
|
|
book = await rulebooks_svc.create_rulebook(uid, "Sweep fixture")
|
|
topic = await rulebooks_svc.create_topic(book.id, uid, "mixed")
|
|
decision = await rulebooks_svc.create_rule(
|
|
topic.id, uid, "dev is home", "Work directly on dev.",
|
|
)
|
|
never = await rulebooks_svc.create_rule(
|
|
topic.id, uid, "The runner has no bash", "Use POSIX sh.",
|
|
verify_with="read the workflow's shell setting",
|
|
)
|
|
stale = await rulebooks_svc.create_rule(
|
|
topic.id, uid, "Bumps need a dashboard tick", "Tick it first.",
|
|
verify_with="cat CI-runner/renovate/config.js",
|
|
)
|
|
async with async_session() as s:
|
|
row = await s.get(Rule, stale.id)
|
|
row.verified_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
await s.commit()
|
|
return {
|
|
"uid": uid, "decision": decision.id,
|
|
"never": never.id, "stale": stale.id,
|
|
}
|
|
|
|
|
|
async def test_a_rule_with_no_check_is_never_in_the_sweep(rulebook_of_three):
|
|
"""The common case, and the one that keeps the list worth reading.
|
|
|
|
Most rules are decisions. If they appeared here the sweep would be the
|
|
rulebook, and nobody would read it twice.
|
|
"""
|
|
rules = await rulebooks_svc.rules_due_for_verification(rulebook_of_three["uid"])
|
|
assert rulebook_of_three["decision"] not in [r.id for r in rules]
|
|
|
|
|
|
async def test_never_checked_outranks_checked_long_ago(rulebook_of_three):
|
|
"""NULLS FIRST is the ordering decision this surface turns on.
|
|
|
|
Postgres sorts NULLs LAST by default on an ASC ordering, which would put
|
|
the rules nobody has ever confirmed at the BOTTOM — behind every rule
|
|
that at least once had someone look at it. That is exactly backwards: a
|
|
claim with no evidence at all outranks an old one.
|
|
"""
|
|
ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
|
rulebook_of_three["uid"]
|
|
)]
|
|
assert ids.index(rulebook_of_three["never"]) < ids.index(rulebook_of_three["stale"])
|
|
|
|
|
|
async def test_verifying_a_rule_moves_it_off_the_top(rulebook_of_three):
|
|
"""The loop closing: check it, stamp it, and it stops being the question."""
|
|
await rulebooks_svc.mark_rule_verified(
|
|
rulebook_of_three["never"], rulebook_of_three["uid"], still_true=True,
|
|
)
|
|
ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
|
rulebook_of_three["uid"]
|
|
)]
|
|
# Still present — verified is not retired, and it will come due again.
|
|
assert rulebook_of_three["never"] in ids
|
|
assert ids.index(rulebook_of_three["stale"]) < ids.index(rulebook_of_three["never"])
|
|
|
|
|
|
async def test_a_failed_check_writes_nothing(rulebook_of_three):
|
|
"""The asymmetry that keeps the sweep honest.
|
|
|
|
There is no "verified false" state, because a rule whose check failed is
|
|
not in a special condition — it is WRONG. Recording the failure would let
|
|
it sit there being false with the sweep satisfied that someone looked.
|
|
"""
|
|
before = await _row(rulebook_of_three["stale"])
|
|
await rulebooks_svc.mark_rule_verified(
|
|
rulebook_of_three["stale"], rulebook_of_three["uid"], still_true=False,
|
|
)
|
|
after = await _row(rulebook_of_three["stale"])
|
|
assert after.verified_at == before.verified_at
|
|
|
|
|
|
async def test_a_rule_with_no_check_cannot_be_verified(rulebook_of_three):
|
|
"""Nothing to verify is a different answer from verified — and stamping
|
|
one would put a decision into a sweep it has no business being in."""
|
|
assert await rulebooks_svc.mark_rule_verified(
|
|
rulebook_of_three["decision"], rulebook_of_three["uid"],
|
|
) is None
|
|
|
|
|
|
async def test_never_only_and_the_age_filter_narrow_to_what_they_say(rulebook_of_three):
|
|
uid = rulebook_of_three["uid"]
|
|
# Membership, not equality: the integration lane shares one database for
|
|
# the whole run and this fixture is function-scoped, so this owner has
|
|
# accumulated rules from earlier tests. Asserting the exact list would
|
|
# pass alone and fail in the suite.
|
|
never_ids = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
|
uid, never_only=True,
|
|
)]
|
|
assert rulebook_of_three["never"] in never_ids
|
|
assert rulebook_of_three["stale"] not in never_ids
|
|
assert rulebook_of_three["decision"] not in never_ids
|
|
|
|
# A rule checked in January is well past any sane window; one never
|
|
# checked always qualifies, because it is the most overdue thing there is.
|
|
aged = [r.id for r in await rulebooks_svc.rules_due_for_verification(
|
|
uid, older_than_days=30,
|
|
)]
|
|
assert rulebook_of_three["stale"] in aged
|
|
assert rulebook_of_three["never"] in aged
|
|
|
|
|
|
|
|
|
|
async def test_another_users_rules_are_not_in_your_sweep(rulebook_of_three):
|
|
"""Rules are ownership-scoped: there is no rule-sharing ACL in this
|
|
schema, so the only correct answer is your own rules."""
|
|
async with async_session() as s:
|
|
stranger = await ensure_user(s, "sweep_stranger")
|
|
sid = stranger.id
|
|
await s.commit()
|
|
|
|
assert await rulebooks_svc.rules_due_for_verification(sid) == []
|