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
@@ -0,0 +1,65 @@
"""rule_usage_events carry an outcome, not just a read (#4212, milestone 419)
Revision ID: 0106
Revises: 0105
Create Date: 2026-09-20
Milestone 419's first step. `rule_usage_events` can say a rule was SURFACED
and that it was PULLED. It cannot say what happened next, so these two
sessions leave identical telemetry:
- a rule surfaced, opened, and followed;
- a rule surfaced, opened, and silently ignored.
The second is the more urgent by a distance, and it is the one the readout
cannot name. That is the whole of what this milestone is about, measured on a
session where three of seven misses were caught by the operator and none by
the system.
TWO NEW EVENT VALUES, AND NO CHECK MIGRATION. `event` was created in 0094 as
plain `sa.Text()` with no constraint — checked in the migration itself, not
assumed from the model — so `applied` and `departed` join `surfaced` and
`pulled` without a DROP/ADD pair. Rule 36 governs CHECK-whitelisted columns
and this is not one; noted explicitly because the next reader will reach for
rule 36 here, and should be able to see in one place why it does not bite.
THE THIRD STATE IS DERIVED, AND THAT IS NOT A SHORTCUT. Read-and-silently-
unchanged is the absence of an outcome, and it has to be: an agent that knew
it was ignoring a rule would not be ignoring it. There is no honest way to ask
for that event, so nothing here tries. `applied` and `departed` are reported;
the third state is what is left over when a rule was pulled and neither
arrived. A schema that offered an `ignored` value would collect nothing and
read as though it had measured something, which is the #3311 failure — a
statistic that cannot vary being mistaken for a finding.
`detail` CARRIES THE WHY OF A DEPARTURE, and is the reason this is a column
rather than two more bare event strings. A departure without its reason is
indistinguishable from a miss when someone reads the table back, so the two
states the milestone wants to tell apart would collapse again one layer down.
Nullable because `applied` needs no argument — following a rule is the
unremarkable case, and demanding prose for it would make the cheap event
expensive and stop it being recorded at all.
NO NEW INDEX. Every outcome readout starts from a set of rule ids and narrows
by event, which is exactly `ix_rule_usage_rule_event` (rule_id, event) from
0094. Adding a `detail` index would serve no query anyone has — the column is
read, never filtered on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0106"
down_revision = "0105"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rule_usage_events",
sa.Column("detail", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("rule_usage_events", "detail")
+8
View File
@@ -185,6 +185,14 @@ _WRITE_TOOLS = frozenset({
"create_rule", "create_project_rule", "update_rule", "move_rule", "delete_rule",
"create_preference", "update_preference",
"relate_rules", "unrelate_rules", "mark_rule_verified",
# rule_outcome writes only telemetry, which is the case _READ_ONLY_TOOLS
# above explicitly tolerates for getters that call record_pulled. It is
# classed as a WRITE anyway, on the difference that matters: those are
# reads that happen to leave a trace, while this tool's entire effect is
# the row — and the row carries `detail`, free prose the agent authored.
# A read-scoped key that can put text into the operator's database is not
# read-scoped, whatever table it lands in (#4212).
"rule_outcome",
# retrieval tuning — a write in both senses: it moves the number the arm
# reads, and it appends the reason to the audit trail (#4102).
"tune_retrieval",
+64 -2
View File
@@ -18,7 +18,9 @@ from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
from scribe.services.rule_usage import record_rule_pulled
from scribe.services.rule_usage import (
record_rule_outcome, record_rule_pulled,
)
# ── Rulebook CRUD ───────────────────────────────────────────────────────
@@ -245,6 +247,66 @@ async def get_rule(rule_id: int) -> dict:
return await rulebooks_svc.rule_detail(uid, rule)
async def rule_outcome(rule_id: int, outcome: str, why: str = "") -> dict:
"""Record what a rule you read ACTUALLY CHANGED — applied, or departed from.
Call this after a rule has been surfaced to you and you have acted. It is
the only way the system can tell a rule that is working from a rule that
is being read and ignored: `surfaced` says it was offered, `get_rule` says
it was opened, and until this exists neither says whether it made any
difference. A rule obeyed every time and a rule ignored every time leave
identical telemetry, and the second is the one worth knowing about.
`outcome` is one of:
"applied" — it changed what you did, or it confirmed the approach you
were already taking. `why` is optional; following a rule is
the ordinary case and does not need an argument.
"departed" — you read it and deliberately did not follow it. `why` is
REQUIRED and is the whole value of the call: a departure
without its reason is indistinguishable from a miss when
somebody reads this back, and "somebody" is usually you, in
a later session, with none of today's context.
There is deliberately NO value for "read it and ignored it". That state is
real, and it is the one this measurement exists to expose — but it is not
something you can report, because noticing it is the same act as not doing
it. It is derived instead: a rule you opened and never came back to. The
honest way to keep yourself out of that bucket is to call this, not to
reach for a word that describes it.
A departure is a legitimate answer and is not a confession. Rules are
written for the common case; recording the edge you found is how the rule
gets better, and a corpus where nothing is ever departed from is a corpus
nobody is really reading.
"""
uid = current_user_id()
rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
choice = (outcome or "").strip().lower()
if choice not in ("applied", "departed"):
raise ValueError(
f"outcome must be 'applied' or 'departed', got {outcome!r}"
)
if choice == "departed" and not (why or "").strip():
raise ValueError(
"a departure needs its reason — pass `why`. Without it the record "
"cannot be told from a rule that was simply missed."
)
record_rule_outcome(
user_id=uid, rule_id=int(rule.id), outcome=choice,
source="mcp_rule_outcome", detail=why,
)
return {
"rule_id": int(rule.id),
"title": rule.title,
"outcome": choice,
"why": (why or "").strip() or None,
"recorded": True,
}
async def create_rule(
topic_id: int, title: str, statement: str, when_to_apply: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
@@ -1086,7 +1148,7 @@ def register(mcp) -> None:
for fn in (
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
list_topics, create_topic, update_topic, delete_topic,
list_rules, get_rule,
list_rules, get_rule, rule_outcome,
create_rule, create_project_rule, update_rule, move_rule, delete_rule,
create_preference, update_preference,
relate_rules, unrelate_rules,
+36 -2
View File
@@ -7,9 +7,24 @@ from scribe.models.base import CreatedAtMixin, iso
SURFACED = "surfaced"
PULLED = "pulled"
# The outcome half (#4212, milestone 419). A rule that was read and then
# ignored has always been indistinguishable from one that was read and
# obeyed; these are the two events that can tell them apart.
#
# There is deliberately NO third value for "read and ignored". That state is
# real and is the whole point of the milestone, but it cannot be reported:
# an agent that knew it was ignoring a rule would not be ignoring it. It is
# DERIVED — a pull with no outcome — and an enum member for it would collect
# nothing while reading as though it had measured something, which is #3311's
# failure exactly.
APPLIED = "applied"
DEPARTED = "departed"
OUTCOMES = (APPLIED, DEPARTED)
class RuleUsageEvent(Base, CreatedAtMixin):
"""One row per time a rule was SURFACED to the agent, or PULLED in full.
"""One row per time a rule was SURFACED to the agent, PULLED in full, or
ACTED ON — applied, or departed from with a stated reason.
The sibling `note_usage_events` has had since 2026-07, third in the line
after `rule_embeddings` and `rule_versions` — and, like those, it exists
@@ -58,7 +73,13 @@ class RuleUsageEvent(Base, CreatedAtMixin):
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
rule_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
# 'surfaced' | 'pulled'
# 'surfaced' | 'pulled' | 'applied' | 'departed'
#
# Plain Text with no CHECK, as created in 0094 — which is why 0106 added
# the outcome pair without a DROP/ADD migration. Rule 36 governs
# CHECK-whitelisted columns and this is not one. Said here as well as in
# the migration because this is where the next person adding a value will
# look first.
event: Mapped[str] = mapped_column(Text, nullable=False)
# Which surface produced it. A CONVENTION, not a fixed vocabulary, and the
@@ -76,6 +97,18 @@ class RuleUsageEvent(Base, CreatedAtMixin):
# without saying why.
source: Mapped[str] = mapped_column(Text, nullable=False)
# The WHY of a departure, and the reason the outcome pair is not simply
# two more bare event strings. A departure stripped of its reason reads
# back as a miss, so the two states this table exists to separate would
# collapse again one layer down — in the readout, where nobody would see
# it happen.
#
# Nullable because `applied` needs no argument. Following a rule is the
# unremarkable case; demanding prose for it would make the cheap event
# expensive, and an expensive event is one that stops being recorded.
# Empty on a `surfaced` or `pulled` row, which nobody asks a reason of.
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
# Every readout is "these rule ids, split by event" — a covering
# composite beats separate single-column indexes for it.
@@ -92,4 +125,5 @@ class RuleUsageEvent(Base, CreatedAtMixin):
"rule_id": self.rule_id,
"event": self.event,
"source": self.source,
"detail": self.detail,
}
+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)
@@ -25,7 +25,9 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
from scribe.models.rule_usage import (
APPLIED, DEPARTED, PULLED, SURFACED, RuleUsageEvent,
)
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User
from scribe.services import backup
@@ -141,6 +143,21 @@ async def source():
user_id=None, rule_id=rule.id,
event=SURFACED, source="write_path_rule",
),
# A departure and its reason (#4212). Seeded here because
# `detail` is the ONE field on this table that cannot be
# recomputed: a fresh install re-earns its counts by being used,
# but a stated reason exists once and is gone if a restore drops
# it — and a `departed` row that comes back reasonless reads as a
# rule that was simply missed.
RuleUsageEvent(
user_id=uid, rule_id=rule.id,
event=DEPARTED, source="mcp_rule_outcome",
detail="the integration lane has no registry credentials",
),
RuleUsageEvent(
user_id=uid, rule_id=rule.id,
event=APPLIED, source="mcp_rule_outcome",
),
])
await s.commit()
book_id, rule_id, note_id = book.id, rule.id, note.id
@@ -232,8 +249,8 @@ async def restored(source):
async def test_every_event_comes_back(restored):
"""The count first: every shape assertion below reads the same on an empty
list, so without this a restore that dropped all three would pass them."""
assert len(restored["events"]) == 3
list, so without this a restore that dropped all five would pass them."""
assert len(restored["events"]) == 5
async def test_the_events_attach_to_the_RESTORED_rule(restored):
@@ -272,7 +289,9 @@ async def test_the_actor_is_remapped_and_a_missing_one_survives(restored):
surfacings of exactly the surface being measured."""
attributed = [e for e in restored["events"] if e.user_id is not None]
orphaned = [e for e in restored["events"] if e.user_id is None]
assert len(attributed) == 2
# Four attributed: the surfacing, the pull, and the two outcome rows
# added with `detail` (#4212). One orphaned, deliberately.
assert len(attributed) == 4
assert len(orphaned) == 1, (
"the event with no actor did not come back. Telemetry outlives the "
"account it was recorded for; dropping it silently lowers the "
@@ -289,6 +308,40 @@ async def test_the_event_and_source_survive(restored):
assert pairs == {
(SURFACED, "write_path_rule"),
(PULLED, "mcp_get_rule"),
(DEPARTED, "mcp_rule_outcome"),
(APPLIED, "mcp_rule_outcome"),
}
assert sum(1 for e in restored["events"] if e.event == SURFACED) == 2
assert sum(1 for e in restored["events"] if e.event == PULLED) == 1
async def test_a_departures_reason_survives_the_round_trip(restored):
"""The one field here that a fresh install cannot re-earn.
Counts come back by being used again; a stated reason exists once. A
restore that kept the `departed` row and dropped its `detail` would turn
a deliberate, argued departure into something indistinguishable from a
rule that was read and missed — which is the exact distinction milestone
419 was opened to create, undone silently at the one moment nobody is
watching.
#4197 is the standing warning behind this test: the backup column guard
watches the export side only, so a column added to the model and to the
exporter and NOT to the importer round-trips as null with nothing to say
so.
"""
departures = [e for e in restored["events"] if e.event == DEPARTED]
assert len(departures) == 1
assert departures[0].detail == (
"the integration lane has no registry credentials"
)
async def test_an_application_carries_no_reason_and_that_is_not_a_loss(restored):
"""`applied` is the unremarkable case and is stored reasonless on
purpose. Asserted so that a later change making `detail` NOT NULL — or
backfilling it with a placeholder — has to argue with a test rather than
quietly make every application look like it had something to say."""
applications = [e for e in restored["events"] if e.event == APPLIED]
assert len(applications) == 1
assert applications[0].detail is None
+104
View File
@@ -478,3 +478,107 @@ async def test_move_rule_on_someone_elses_rule_is_not_found():
from scribe.mcp.tools.rulebooks import move_rule
with pytest.raises(ValueError, match="not found"):
await move_rule(rule_id=94, project_id=3)
# ── rule_outcome: what a rule actually changed (#4212, milestone 419) ─────
@pytest.mark.asyncio
async def test_rule_outcome_records_an_application():
rule = fake_rule(id=156, title="`dev` is home")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
out = await rule_outcome(rule_id=156, outcome="applied")
assert out["outcome"] == "applied" and out["recorded"] is True
assert out["why"] is None
assert rec.call_args.kwargs["outcome"] == "applied"
assert rec.call_args.kwargs["source"] == "mcp_rule_outcome"
@pytest.mark.asyncio
async def test_rule_outcome_records_a_departure_with_its_reason():
rule = fake_rule(id=156, title="`dev` is home")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
out = await rule_outcome(
rule_id=156, outcome="departed", why="the operator asked for main"
)
assert out["outcome"] == "departed"
assert rec.call_args.kwargs["detail"] == "the operator asked for main"
@pytest.mark.asyncio
async def test_a_departure_without_a_reason_is_refused_at_the_door():
"""Refused with a message that says WHY a reason is needed, not just
that one is missing — the caller is an agent deciding whether to bother,
and "it cannot be told from a miss" is the argument that lands."""
rule = fake_rule(id=156, title="`dev` is home")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
with pytest.raises(ValueError, match="departure needs its reason"):
await rule_outcome(rule_id=156, outcome="departed", why=" ")
rec.assert_not_called()
@pytest.mark.asyncio
async def test_there_is_no_way_to_report_having_ignored_a_rule():
"""Deliberate, and the reason is in the tool's docstring: noticing that
you ignored a rule is the same act as not ignoring it, so the state is
derived rather than reported. A caller reaching for the word gets an
error rather than a row that would read as measurement."""
rule = fake_rule(id=156, title="`dev` is home")
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=rule),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
for bogus in ("ignored", "skipped", "read", ""):
with pytest.raises(ValueError, match="must be 'applied' or 'departed'"):
await rule_outcome(rule_id=156, outcome=bogus)
rec.assert_not_called()
@pytest.mark.asyncio
async def test_rule_outcome_refuses_a_rule_the_caller_cannot_see():
"""The access check comes FIRST, so a miss cannot be used to probe for
the existence of someone else's rule, and nothing is recorded against
an id the caller has no claim on."""
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
AsyncMock(return_value=None),
), patch(
"scribe.mcp.tools.rulebooks.record_rule_outcome", MagicMock()
) as rec:
from scribe.mcp.tools.rulebooks import rule_outcome
with pytest.raises(ValueError, match="rule 999 not found"):
await rule_outcome(rule_id=999, outcome="applied")
rec.assert_not_called()
def test_rule_outcome_is_registered_and_is_not_read_only():
"""Its whole effect is a write, and `why` is 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."""
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.mcp.tools import rulebooks as mod
mcp = FakeMCP()
mod.register(mcp)
assert "rule_outcome" in mcp.names
assert "rule_outcome" in _WRITE_TOOLS
assert "rule_outcome" not in _READ_ONLY_TOOLS
+137 -1
View File
@@ -6,7 +6,9 @@ where a mistake is silent rather than loud.
"""
import pytest
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
from scribe.models.rule_usage import (
APPLIED, DEPARTED, PULLED, SURFACED, RuleUsageEvent,
)
from scribe.services import rule_usage
@@ -101,6 +103,9 @@ def test_the_zero_readout_names_every_key():
"surfaced_count": 0,
"ambient_count": 0,
"pull_count": 0,
"applied_count": 0,
"departed_count": 0,
"last_outcome_at": None,
"last_surfaced_at": None,
"last_pulled_at": None,
}
@@ -255,3 +260,134 @@ async def test_a_preloaded_rule_does_not_read_as_a_ranked_surfacing(_dispose_eng
delete(RuleUsageEvent).where(RuleUsageEvent.user_id == 990021)
)
await s.commit()
# ── the outcome stream (#4212, milestone 419) ─────────────────────────────
#
# What these guard is a distinction, not a payload. Before this existed, a
# rule read and obeyed and a rule read and ignored left byte-identical
# telemetry, so the readout could not name the failure the whole milestone
# was opened on. The tests that matter most below are the ones asserting
# that a REASONLESS DEPARTURE IS NEVER WRITTEN, and that an unacted rule is
# a state in its own right rather than the absence of one.
def test_an_applied_outcome_needs_no_argument(captured):
"""Following a rule is the ordinary case. Charging prose for it would
make the cheap event expensive, and an expensive event stops being
recorded — which costs the whole measurement."""
rule_usage.record_rule_outcome(
user_id=7, rule_id=156, outcome=APPLIED, source="mcp_rule_outcome"
)
[batch] = captured
assert batch == [{
"user_id": 7, "rule_id": 156, "event": APPLIED,
"source": "mcp_rule_outcome", "detail": None,
}]
def test_a_departure_carries_its_reason(captured):
rule_usage.record_rule_outcome(
user_id=7, rule_id=156, outcome=DEPARTED, source="mcp_rule_outcome",
detail=" the integration lane has no registry credentials ",
)
[batch] = captured
assert batch[0]["event"] == DEPARTED
assert batch[0]["detail"] == "the integration lane has no registry credentials"
@pytest.mark.parametrize("reason", ["", " ", "\n", None])
def test_a_departure_with_no_reason_is_never_written(captured, reason):
"""THE ONE THAT MATTERS. A `departed` row without its why reads back as a
miss, so writing one would collapse the two states this table exists to
separate — silently, in the readout, where nobody would see it happen.
Dropped and reported, never stored: telemetry that lies is worse than
telemetry that is absent (#2663)."""
rule_usage.record_rule_outcome(
user_id=7, rule_id=156, outcome=DEPARTED,
source="mcp_rule_outcome", detail=reason or "",
)
assert captured == []
def test_an_unknown_outcome_is_never_written(captured):
"""Including the one somebody will reach for. There is no `ignored`
event by design — see `record_rule_outcome` — and a caller inventing one
must not get a row that reads as though the state were measurable."""
for bogus in ("ignored", "skipped", "surfaced", "", "APPLIED "):
rule_usage.record_rule_outcome(
user_id=7, rule_id=156, outcome=bogus, source="mcp_rule_outcome"
)
assert captured == []
def test_an_outcome_row_is_one_row(captured):
"""A judgement is about one rule. Unlike a surfacing, which delivers a
whole hint at once, there is no batch shape to get wrong here — asserted
so that a later 'helpful' bulk variant has to change a test that says
why."""
rule_usage.record_rule_outcome(
user_id=7, rule_id=1, outcome=APPLIED, source="mcp_rule_outcome"
)
[batch] = captured
assert len(batch) == 1
# ── the four states, read off the aggregate ───────────────────────────────
def _usage(**kw):
base = rule_usage.empty_rule_usage()
base.update(kw)
return base
def test_a_rule_surfaced_and_never_opened_is_unread():
assert rule_usage.outcome_state(_usage(surfaced_count=4)) == rule_usage.UNREAD
def test_a_rule_opened_and_acted_on_is_followed():
assert rule_usage.outcome_state(
_usage(surfaced_count=4, pull_count=1, applied_count=1)
) == rule_usage.FOLLOWED
def test_a_rule_opened_and_departed_from_is_departed():
assert rule_usage.outcome_state(
_usage(surfaced_count=4, pull_count=1, departed_count=1)
) == rule_usage.DEPARTED_FROM
def test_a_rule_opened_and_never_acted_on_is_unacted() -> None:
"""THE STATE THAT DID NOT EXIST, and the reason for the milestone. Not
"no data": the rule was surfaced, deliberately opened, and then left no
trace of having mattered. Until now that was arithmetically identical to
compliance, which is why nothing could report it."""
assert rule_usage.outcome_state(
_usage(surfaced_count=4, pull_count=2)
) == rule_usage.UNACTED
def test_unacted_and_followed_are_not_the_same_reading():
"""Stated as its own test because it IS the milestone in one line. If a
change ever makes these two agree, the measurement is gone and every
other test here would still pass."""
opened_only = _usage(surfaced_count=4, pull_count=2)
opened_and_applied = _usage(surfaced_count=4, pull_count=2, applied_count=1)
assert rule_usage.outcome_state(opened_only) != rule_usage.outcome_state(
opened_and_applied
)
def test_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. Reporting it as plain
compliance would bury the one row a reader most wants to see."""
assert rule_usage.outcome_state(
_usage(pull_count=3, applied_count=5, departed_count=1)
) == rule_usage.DEPARTED_FROM
def test_the_state_reads_the_aggregate_the_readout_already_returns():
"""`outcome_state` takes `usage_for_rules`' own shape, so the badge, the
readout and any later session summary cannot disagree about what
"followed" means — the drift #3246 found across the rules system."""
assert rule_usage.outcome_state(rule_usage.empty_rule_usage()) == rule_usage.UNREAD