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
130 lines
6.5 KiB
Python
130 lines
6.5 KiB
Python
from sqlalchemy import BigInteger, Index, Text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from scribe.models import Base
|
|
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, 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
|
|
because the rule side kept inheriting machinery built for notes and
|
|
quietly getting the weaker version of it.
|
|
|
|
WHY RULES NEED THEIR OWN AND CANNOT SHARE THE NOTE TABLE. Not squeamishness
|
|
about a polymorphic column — the row shares no note-specific fields and the
|
|
aggregate readout is the same shape, which is the strongest case for
|
|
sharing that note #3163 admits. What decides it is IDENTITY AT RESTORE. A
|
|
note id and a rule id are different namespaces resolved through different
|
|
maps, and `note_usage_events`'s importer maps `note_id` through
|
|
`note_id_map` and drops what does not resolve. A rule id parked in that
|
|
column would come back from a backup silently reattached to whatever note
|
|
happened to take that number — telemetry that is not merely lost but wrong,
|
|
and wrong in a way nothing downstream could detect.
|
|
|
|
WHAT THIS MEASURES, AND WHY IT DID NOT EXIST. The write-path standing-rule
|
|
arm is the only retrieval surface in Scribe whose usefulness cannot be
|
|
observed — and, not coincidentally, the only one that has never declined to
|
|
fire (#3311: 296 calls, zero zero-result, 100% clearing its threshold).
|
|
`retrieval_logs` gives it scores; scores say what the ranker thought, never
|
|
whether the hint landed. Without a pull counter no install can tune the arm
|
|
from evidence, only from the shape of a histogram.
|
|
|
|
Deliberately FK-FREE on `rule_id` and `user_id`, matching `note_usage_events`,
|
|
`retrieval_logs` and `app_logs` — and diverging from `rule_versions`, which
|
|
does carry FKs. The difference is what the row is FOR: a version is part of
|
|
a rule's history and dies with it, while telemetry outlives the row it
|
|
describes. Deleting a rule must not erase the evidence that it was surfaced
|
|
forty times and opened never, because that evidence is precisely the case
|
|
for having deleted it.
|
|
|
|
Cells left deliberately empty (note #3163's step 3): no share ACL — rules
|
|
have none of their own; no soft delete — nothing restores a telemetry row,
|
|
and the table is append-only; no embedding — an event is not a document.
|
|
"""
|
|
|
|
__tablename__ = "rule_usage_events"
|
|
|
|
# BigInteger throughout, where the note twin uses Integer. `rule_id` has to
|
|
# be, since `rules.id` is BigInteger — and once one column is, matching the
|
|
# rest costs nothing and keeps the row uniform. A high-churn append-only
|
|
# telemetry table is a poor place to discover an id ceiling.
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
rule_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
|
|
# '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
|
|
# note twin's comment explains why this one deliberately does not enumerate
|
|
# its members: the previous such list went stale, naming a source nothing
|
|
# wrote while omitting ones that existed, and a half-true enumeration reads
|
|
# as authoritative in exactly the way that misleads (#2476).
|
|
# `grep -rn record_rule_pulled\|record_rule_surfaced src/` is the
|
|
# authoritative list, and unlike a comment it cannot drift.
|
|
#
|
|
# The mcp_/rest_ prefix split is load-bearing here for the same reason it is
|
|
# for notes, and more so: "is this rule dead weight?" is served by any pull,
|
|
# but "did that injected hint land?" — the question this arm exists to
|
|
# answer — is served by AGENT pulls only. Never aggregate across the prefix
|
|
# 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.
|
|
Index("ix_rule_usage_rule_event", "rule_id", "event"),
|
|
Index("ix_rule_usage_created_at", "created_at"),
|
|
Index("ix_rule_usage_user_id", "user_id"),
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"created_at": iso(self.created_at),
|
|
"user_id": self.user_id,
|
|
"rule_id": self.rule_id,
|
|
"event": self.event,
|
|
"source": self.source,
|
|
"detail": self.detail,
|
|
}
|