feat(rules): the staleness sweep — which standing rules assert a fact nobody has confirmed (#3097, milestone 312 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 35s

The query the last two steps were storage for. `rules_due_for_verification`
returns every rule carrying a `verify_with`, ordered by `verified_at` ASC
NULLS FIRST, each row carrying the check IN FULL — the opposite call from
rule_brief, because the reader is about to go and run it.

NULLS FIRST is the ordering this turns on. Postgres sorts NULLs last on an
ASC ordering, which would put the rules nobody has ever confirmed BEHIND
every rule someone once looked at. Exactly backwards: a claim with no
evidence at all outranks an old one.

Rules with no check never appear, and that is the property that keeps the
list worth reading. Most rules are decisions — no truth value, nothing to go
and check. If they appeared here the sweep would be the rulebook.

`mark_rule_verified(rule_id, still_true)` closes the loop, asymmetrically:
passing writes a stamp, FAILING WRITES NOTHING. There is no "verified false"
state because a rule whose check failed is not in a special condition, it is
wrong — and recording the failure as a flag would let it sit there being
false with the sweep satisfied that someone had looked. So it stays at the
top until someone corrects or retires it, and the response says so.

An unrecognised `tier` filter raises rather than falling back. _valid_tier's
silent always_on default is right for a WRITE — a typo should leave a rule
binding — and wrong for a FILTER, where the same fallback quietly answers a
different question and returns a short list that reads as good news.

Deliberately NOT filterable by project: a project reaches rules through
project scope, subscriptions, always-on rulebooks and exclusions, and a
filter missing one of those paths would UNDER-report — the exact failure
this surface exists to prevent. Said so in the docstring rather than
shipping a half-correct filter.

Ownership-scoped like every other rule read (owned rulebook, or owned
project), in ONE statement with an OR across the XOR rather than two queries
merged in Python, so the ordering is the database's and cannot disagree with
itself. Note that rules have no sharing ACL in this schema — no rule_shares,
no rulebook_shares — so there is no wider set for access.py to consult here.

Also fixes a test title that had been lying for ten tools: "all sixteen
tools" asserted 26. The number now lives only in the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 10:49:47 -04:00
co-authored by Claude Opus 5
parent 469b43f222
commit 410d616c22
7 changed files with 560 additions and 4 deletions
+59
View File
@@ -446,3 +446,62 @@ def test_the_check_text_itself_never_enters_a_listing():
))
assert "verify_with" not in out
assert "expires_when" not in out
# ── the sweep's row shape (milestone 312 step 3) ────────────────────────
def test_a_sweep_row_carries_the_check_in_full():
"""The OPPOSITE call from rule_brief, and deliberately so.
A listing omits the depth because nobody reading it wants to act on one
rule. A sweep row exists to be acted on — the reader is about to go and
run the check — so the text is the payload's point, not its bloat.
"""
from scribe.services.rulebooks import verification_row
row = verification_row(fake_rule(
verify_with="cat CI-runner/renovate/config.js",
expires_when="dependencyDashboardApproval is turned off",
when_to_apply="when a dependency bump is in play",
))
assert row["verify_with"] == "cat CI-runner/renovate/config.js"
assert row["expires_when"] == "dependencyDashboardApproval is turned off"
assert row["when_to_apply"] == "when a dependency bump is in play"
assert row["tier"] == "always_on"
def test_never_verified_reports_no_day_count_rather_than_zero():
""""Never" is not "0 days ago" — the second reads as freshly checked.
Getting this wrong would invert the row's meaning for exactly the rules
that most need attention.
"""
from scribe.services.rulebooks import verification_row
row = verification_row(fake_rule(verify_with="read the workflow"))
assert row["last_verified"] == "never"
assert row["days_since_verified"] is None
def test_a_verified_row_counts_the_days():
from datetime import timedelta
from scribe.services.rulebooks import verification_row
row = verification_row(fake_rule(
verify_with="read the workflow",
verified_at=datetime.now(timezone.utc) - timedelta(days=74, hours=1),
))
assert row["days_since_verified"] == 74
@pytest.mark.asyncio
async def test_an_unrecognised_tier_filter_raises_rather_than_narrowing():
"""_valid_tier's silent always_on fallback is right for a WRITE — a typo
should leave a rule binding. It is wrong for a FILTER, where the same
fallback would quietly answer a different question than the one asked and
return a short list that looks like good news."""
from scribe.services.rulebooks import rules_due_for_verification
with pytest.raises(ValueError, match="tier must be one of"):
await rules_due_for_verification(7, tier="occasionally")