feat(rules): a surfaced rule gets an outcome, not just a read (#4212)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m6s
CI & Build / Build & push image (push) Skipped
Milestone 419 step 1. `rule_usage_events` could say a rule was SURFACED and that it was PULLED. It could not say what happened next, so a rule that fires constantly and is always obeyed and a rule that fires constantly and is never obeyed left byte-identical telemetry. The second is far the more urgent and was the one the readout could not name — measured on a session where three of seven misses were caught by the operator and none by the system. Two new events, `applied` and `departed`, and a `detail` column carrying the why of a departure. No CHECK migration: `event` was created in 0094 as plain Text with no constraint, verified in the migration rather than assumed from the model, so rule 36 does not bite here — said in both places because the next person adding a value will reach for it. THE THIRD STATE IS DERIVED, AND THAT IS THE DESIGN. Read-and-silently- unchanged is the failure this milestone was opened on, and it cannot be reported: an agent that knew it was ignoring a rule would not be ignoring it. So nothing here asks. `applied` and `departed` are reported; the third state is a rule that was opened and left no trace. An `ignored` enum member would collect nothing while reading as though it had measured something, which is #3311's failure — a statistic that could not vary being taken for a finding. `detail` is a column rather than two more bare event strings because a departure stripped of its reason reads back as a miss, so the two states this exists to separate would collapse again one layer down, in the readout, where nobody would see it happen. Nullable: following a rule needs no argument, and an expensive event is one that stops being recorded. `outcome_state` is the single reading of the four states, taking the aggregate `usage_for_rules` already returns, so the badge, the readout and any later session summary cannot disagree about what "followed" means — the drift #3246 found across the rules system. A departure outranks an application: a rule both applied and argued with is a rule someone argued with, and the argument is the half worth surfacing. `rule_outcome` is the MCP door, classed as a WRITE. The read-only set tolerates getters that call record_pulled, but those are reads that leave a trace; this tool's entire effect is the row, and the row carries prose the agent authored. A read-scoped key that can put text in the operator's database is not read-scoped, whatever table it lands in. Backup carries `detail` on both sides. It is the one field here a fresh install cannot re-earn — counts come back by being used again, a stated reason exists once — and #4197 records that the column guard watches the export side only, so the round-trip test is the thing that would catch a one-sided add. Delivery is deliberately not settled here: how an agent gets prompted to record an outcome is step 3's subject, and the same record serves whichever answer that step reaches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -345,6 +345,12 @@ def _rule_usage_event_rows(rows) -> list[dict]:
|
||||
{
|
||||
"user_id": r.user_id, "rule_id": r.rule_id, "event": r.event,
|
||||
"source": r.source,
|
||||
# The reason a rule was departed from (#4212). Exported because
|
||||
# it is the only field on this table that cannot be recomputed:
|
||||
# counts can be re-derived from a fresh install's own use, a
|
||||
# stated reason cannot, and a `departed` row that comes back
|
||||
# without one is indistinguishable from a rule that was missed.
|
||||
"detail": r.detail,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
@@ -1573,6 +1579,12 @@ async def _restore_v2(data: dict) -> dict:
|
||||
rule_id=mapped_rid,
|
||||
event=ev.get("event", ""),
|
||||
source=ev.get("source", ""),
|
||||
# `.get(...) or None` rather than a bare default: an archive
|
||||
# written before 0106 has no key at all, and one written
|
||||
# after may carry "" for a non-departure row. Both mean "no
|
||||
# reason", and both must land as NULL so the readout does not
|
||||
# have to tell an empty string from an absent one.
|
||||
detail=(ev.get("detail") or None),
|
||||
created_at=_dt(ev.get("created_at")),
|
||||
))
|
||||
stats["rule_usage_events"] += 1
|
||||
|
||||
@@ -85,7 +85,9 @@ from sqlalchemy import case, func, select
|
||||
|
||||
from scribe.models import async_session
|
||||
from scribe.models.base import iso
|
||||
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
|
||||
from scribe.models.rule_usage import (
|
||||
APPLIED, DEPARTED, OUTCOMES, PULLED, SURFACED, RuleUsageEvent,
|
||||
)
|
||||
from scribe.services.background import report_telemetry_failure, spawn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -207,6 +209,107 @@ def record_rule_pulled(*, user_id: int | None, rule_id: int, source: str) -> Non
|
||||
_schedule(rows)
|
||||
|
||||
|
||||
def record_rule_outcome(
|
||||
*,
|
||||
user_id: int | None,
|
||||
rule_id: int,
|
||||
outcome: str,
|
||||
source: str,
|
||||
detail: str = "",
|
||||
) -> None:
|
||||
"""Fire-and-forget: record what a rule ACTUALLY CHANGED (#4212).
|
||||
|
||||
The third stream, and the one milestone 419 exists for. `surfaced` says
|
||||
the system offered a rule; `pulled` says somebody opened it. Neither says
|
||||
whether it made any difference, so a rule that fires constantly and is
|
||||
always obeyed and a rule that fires constantly and is never obeyed have,
|
||||
until now, produced identical telemetry. The second is far the more
|
||||
urgent and is precisely the one the readout could not name.
|
||||
|
||||
Two outcomes, because there are only two a judge can honestly report:
|
||||
|
||||
APPLIED — the rule changed what was done, or confirmed it. No `detail`
|
||||
required: following a rule is the unremarkable case and
|
||||
charging prose for it is how an event stops being recorded.
|
||||
DEPARTED — read, and deliberately not followed. `detail` is REQUIRED
|
||||
and is the entire value of the event. A departure without
|
||||
its reason reads back as a miss, which collapses the two
|
||||
states this exists to separate.
|
||||
|
||||
THERE IS NO THIRD CALL, and the absence is the design. Read-and-silently-
|
||||
unchanged is real — it is the failure this milestone was opened on — but
|
||||
it cannot be reported, because an agent that knew it was ignoring a rule
|
||||
would not be ignoring it. It is derived: a rule pulled, with no outcome
|
||||
behind it. See `outcome_state`.
|
||||
|
||||
Guarded rather than trusting: a bad outcome or a reasonless departure is
|
||||
dropped and REPORTED, never written. Telemetry that lies is worse than
|
||||
telemetry that is missing (#2663), and a `departed` row with an empty
|
||||
reason is a lie the readout cannot detect.
|
||||
"""
|
||||
if outcome not in OUTCOMES:
|
||||
logger.warning("rule outcome rejected: unknown outcome %r", outcome)
|
||||
spawn(_report_failure("outcome_unknown"), site="rule_usage_outcome")
|
||||
return
|
||||
if outcome == DEPARTED and not (detail or "").strip():
|
||||
logger.warning("rule outcome rejected: departure with no reason")
|
||||
spawn(_report_failure("outcome_no_reason"), site="rule_usage_outcome")
|
||||
return
|
||||
try:
|
||||
rows = [
|
||||
{
|
||||
"user_id": user_id,
|
||||
"rule_id": int(rule_id),
|
||||
"event": outcome,
|
||||
"source": source,
|
||||
"detail": (detail or "").strip() or None,
|
||||
}
|
||||
]
|
||||
except Exception:
|
||||
logger.debug("rule usage payload build failed", exc_info=True)
|
||||
return
|
||||
_schedule(rows)
|
||||
|
||||
|
||||
# What a rule's usage says happened to it, in one word. The four states are
|
||||
# ordered by how much the system actually knows, and only the last two are
|
||||
# new — the point of the milestone is that UNACTED used to be invisible
|
||||
# inside APPLIED.
|
||||
UNREAD = "unread" # surfaced, never opened
|
||||
UNACTED = "unacted" # opened, and nothing recorded after — the blind spot
|
||||
FOLLOWED = "followed" # opened and applied
|
||||
DEPARTED_FROM = "departed" # opened and deliberately not followed, with a why
|
||||
|
||||
|
||||
def outcome_state(usage: dict) -> str:
|
||||
"""The three states milestone 419 asked to be able to tell apart, plus
|
||||
the one that already existed.
|
||||
|
||||
Pure, and reading only the aggregate `usage_for_rules` already returns —
|
||||
so the readout, the badge and any later session summary all answer this
|
||||
question the same way. Two callers computing "was this followed" from raw
|
||||
counts is the drift #3246 found across the rules system, arriving again.
|
||||
|
||||
PRECEDENCE, and it is deliberate: a departure outranks an application.
|
||||
A rule both applied and departed from in the same window is a rule
|
||||
someone argued with, and the argument is the interesting half — reporting
|
||||
it as plain compliance would hide the one row a reader most wants.
|
||||
|
||||
UNACTED is the derived state and the reason this function exists. It is
|
||||
not "no data"; it is a rule that was surfaced, deliberately OPENED, and
|
||||
then left no trace of having mattered. That is a much stronger signal
|
||||
than never having been opened at all, and it is the signal that was
|
||||
previously indistinguishable from compliance.
|
||||
"""
|
||||
if int(usage.get("departed_count") or 0):
|
||||
return DEPARTED_FROM
|
||||
if int(usage.get("applied_count") or 0):
|
||||
return FOLLOWED
|
||||
if int(usage.get("pull_count") or 0):
|
||||
return UNACTED
|
||||
return UNREAD
|
||||
|
||||
|
||||
def empty_rule_usage() -> dict:
|
||||
"""The zero readout — what a rule with no recorded events looks like.
|
||||
|
||||
@@ -227,6 +330,21 @@ def empty_rule_usage() -> dict:
|
||||
"surfaced_count": 0,
|
||||
"ambient_count": 0,
|
||||
"pull_count": 0,
|
||||
# The outcome half (#4212). Zero here means "nothing recorded", which
|
||||
# for a rule that was also never pulled is simply silence — and for
|
||||
# one that WAS pulled is the blind spot this milestone is named for.
|
||||
# `outcome_state` is what tells those apart; no caller should be
|
||||
# reading these counts raw to decide it.
|
||||
#
|
||||
# The REASON for a departure is on the row (`detail`), not here. One
|
||||
# GROUP BY cannot carry the text of the latest departure without a
|
||||
# DISTINCT ON alongside it, and a key that the aggregate could never
|
||||
# fill would read as "no reason given" on every rule that has one —
|
||||
# a permanently-null field that lies. The readout that needs the
|
||||
# prose reads the rows (#4213).
|
||||
"applied_count": 0,
|
||||
"departed_count": 0,
|
||||
"last_outcome_at": None,
|
||||
"last_surfaced_at": None,
|
||||
"last_pulled_at": None,
|
||||
}
|
||||
@@ -288,7 +406,19 @@ async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
|
||||
slot = out.get(int(rule_id))
|
||||
if slot is None:
|
||||
continue
|
||||
if event == SURFACED and is_amb:
|
||||
# Outcomes first, and never split by ambient. `ambient` asks whether
|
||||
# a RANKER chose to show the rule; an outcome is reported by a judge
|
||||
# after the fact and has no ranker behind it, so the flag is noise
|
||||
# here. Branching on it would silently drop every outcome row into a
|
||||
# bucket nothing reads.
|
||||
if event in OUTCOMES:
|
||||
key = "applied_count" if event == APPLIED else "departed_count"
|
||||
slot[key] = slot[key] + int(n)
|
||||
prev = slot["last_outcome_at"]
|
||||
now = iso(last_at)
|
||||
if now and (prev is None or now > prev):
|
||||
slot["last_outcome_at"] = now
|
||||
elif event == SURFACED and is_amb:
|
||||
slot["ambient_count"] = int(n)
|
||||
elif event == SURFACED:
|
||||
slot["surfaced_count"] = int(n)
|
||||
|
||||
Reference in New Issue
Block a user