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
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:
@@ -11,7 +11,7 @@ import logging
|
||||
from collections.abc import Iterable
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import delete as sql_delete, insert, or_, select
|
||||
from sqlalchemy import and_, delete as sql_delete, insert, or_, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.system import System
|
||||
@@ -1361,3 +1361,150 @@ def rules_payload(applicable: dict) -> dict:
|
||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||
"excluded_always_on": applicable.get("excluded_always_on", []),
|
||||
}
|
||||
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
|
||||
async def rules_due_for_verification(
|
||||
user_id: int,
|
||||
older_than_days: int = 0,
|
||||
tier: str = "",
|
||||
never_only: bool = False,
|
||||
) -> list[Rule]:
|
||||
"""Rules that carry a check, oldest verification first, never-checked top.
|
||||
|
||||
THE QUERY THIS MILESTONE EXISTS FOR. `verify_with` and `expires_when` are
|
||||
storage; this is what turns them into something that gets acted on. The
|
||||
307 audit cost a session and found four broken rules by luck — this makes
|
||||
the same question a list, and staleness measurable by age instead of
|
||||
discoverable by accident.
|
||||
|
||||
Ordered `verified_at` ASC NULLS FIRST: never-checked outranks
|
||||
checked-long-ago, because a rule nobody has ever confirmed is a claim
|
||||
with no evidence behind it at all.
|
||||
|
||||
Rules with no `verify_with` never appear. That is not an omission — they
|
||||
are decisions, there is nothing to go and check, and listing them would
|
||||
dilute the result until nobody reads it.
|
||||
|
||||
Ownership-scoped exactly like list_rules: a rule reached through an owned
|
||||
rulebook, or scoped to an owned project. Rules have no sharing ACL in this
|
||||
schema — no rule_shares, no rulebook_shares — so there is no wider set to
|
||||
consult here, unlike notes and projects.
|
||||
|
||||
Args:
|
||||
user_id: whose rules.
|
||||
older_than_days: only rules last verified longer ago than this.
|
||||
Never-checked rules always qualify — they are the most overdue
|
||||
thing there is. 0 = no age filter.
|
||||
tier: "always_on" or "conditional" to narrow. Raises on anything else
|
||||
rather than falling back: _valid_tier's silent always_on default
|
||||
is right for a WRITE (the safe direction is to keep binding), and
|
||||
wrong for a FILTER, where it would quietly answer a different
|
||||
question than the one asked.
|
||||
never_only: only rules that have never been verified.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from scribe.models.project import Project
|
||||
|
||||
if tier and tier not in TIERS:
|
||||
raise ValueError(f"tier must be one of {TIERS}, got {tier!r}")
|
||||
|
||||
async with async_session() as session:
|
||||
stmt = (
|
||||
select(Rule)
|
||||
.outerjoin(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||
.outerjoin(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.outerjoin(Project, Rule.project_id == Project.id)
|
||||
.where(
|
||||
Rule.deleted_at.is_(None),
|
||||
Rule.verify_with.is_not(None),
|
||||
# One statement rather than two queries merged in Python, so
|
||||
# the ordering below is the database's and cannot disagree
|
||||
# with itself across the two halves of the XOR.
|
||||
or_(
|
||||
and_(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Rulebook.deleted_at.is_(None),
|
||||
RulebookTopic.deleted_at.is_(None),
|
||||
),
|
||||
Project.user_id == user_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
if tier:
|
||||
stmt = stmt.where(Rule.tier == tier)
|
||||
if never_only:
|
||||
stmt = stmt.where(Rule.verified_at.is_(None))
|
||||
elif older_than_days > 0:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||||
stmt = stmt.where(
|
||||
or_(Rule.verified_at.is_(None), Rule.verified_at < cutoff)
|
||||
)
|
||||
stmt = stmt.order_by(Rule.verified_at.asc().nullsfirst(), Rule.id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def verification_row(rule: Rule) -> dict:
|
||||
"""One row of the sweep — the CHECK in full, unlike rule_brief.
|
||||
|
||||
The opposite call from a listing: here the caller is about to go and run
|
||||
the check, so the text they need is the point of the payload rather than
|
||||
the bloat. `days_since` is computed rather than left to the reader,
|
||||
because "2026-06-14" and "74 days" prompt different reactions and only
|
||||
one of them is the question being asked.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
days = None
|
||||
if rule.verified_at is not None:
|
||||
stamp = rule.verified_at
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=timezone.utc)
|
||||
days = (datetime.now(timezone.utc) - stamp).days
|
||||
return {
|
||||
"id": rule.id,
|
||||
"title": rule.title,
|
||||
"statement": rule.statement,
|
||||
"tier": rule.tier,
|
||||
"topic_id": rule.topic_id,
|
||||
"project_id": rule.project_id,
|
||||
"when_to_apply": rule.when_to_apply or "",
|
||||
"verify_with": rule.verify_with or "",
|
||||
"expires_when": rule.expires_when or "",
|
||||
"last_verified": last_verified_label(rule),
|
||||
"days_since_verified": days,
|
||||
}
|
||||
|
||||
|
||||
async def mark_rule_verified(
|
||||
rule_id: int, user_id: int, still_true: bool = True,
|
||||
) -> Optional[Rule]:
|
||||
"""Stamp a rule as verified — or, when the check FAILED, refuse to.
|
||||
|
||||
A failing check is the outcome worth having, and the asymmetry is
|
||||
deliberate: passing writes a stamp, failing writes nothing. There is no
|
||||
"verified false" state to record, because a rule whose check failed is
|
||||
not a rule in a special condition — it is a rule that is WRONG, and the
|
||||
only honest resolutions are to correct it, retire it, or find out why.
|
||||
Recording the failure as a flag would let it sit there being false with
|
||||
the sweep quietly satisfied that someone had looked.
|
||||
|
||||
So a failed check leaves `verified_at` untouched, and the rule stays at
|
||||
the top of the sweep until someone actually deals with it.
|
||||
|
||||
Returns None when the rule is not found, not owned, or carries no
|
||||
`verify_with` — nothing to verify is a different answer from verified.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
async with async_session() as session:
|
||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||
if rule is None or not rule.verify_with:
|
||||
return None
|
||||
if still_true:
|
||||
rule.verified_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(rule)
|
||||
return rule
|
||||
|
||||
Reference in New Issue
Block a user