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

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:
2026-09-20 23:56:05 -04:00
co-authored by Claude Opus 5
parent 0fe19a8440
commit dfcb000719
9 changed files with 615 additions and 11 deletions
+12
View File
@@ -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
+132 -2
View File
@@ -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)