Compare commits

...
2 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 7e9d33d9d9 docs(6205): rule_usage comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 1m6s
CI & Build / integration (push) Successful in 1m24s
CI & Build / Python tests (push) Successful in 2m11s
CI & Build / Build & push image (push) Successful in 26s
The module docstring keeps why rules need their own usage stream and why the
ranked list is the one that is named, and drops the dated story of the
bulk surfaces starting to emit, measured decline rates and the corrected
earlier wording. Outcome docstrings describe the four states without
milestone references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:19:50 -04:00
bvandeusenandClaude Opus 5.5 5d48e23285 docs(6205): notes comments state the reason, not the instance that taught it
Issue, decision, milestone and operator-decision citations are removed;
"previously each route…" and "list_tasks once returned…" stories become the
present-tense reasons they taught. The note about the removed delete_note
now says plainly that there is deliberately none, and why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:18:30 -04:00
2 changed files with 116 additions and 150 deletions
+55 -63
View File
@@ -21,11 +21,9 @@ _PARSED_FROM_BODY = frozenset({"title", "body", "tags"})
def _mirror_recomposers() -> dict[str, Callable[[Note], dict]]:
"""note_type -> the function that rebuilds that kind's derived `data`.
A TABLE rather than a chain of `if note_type == ...`, because the previous
shape tested one constant and the next kind with a derived mirror was
silently not covered — which is exactly what happened: the snippet guard
(#3128) was hard-coded, and lessons arrived in milestone 385 with the same
body-is-authority/`data`-is-mirror design and none of the protection.
A TABLE rather than a chain of `if note_type == ...`, because a chain
tests the constants someone remembered, and the next kind with a derived
mirror (body is authority, `data` is mirror) is silently not covered.
The failure is invisible from here. Nothing raises, nothing logs; the row
simply keeps answering queries from a mirror that no longer matches its
@@ -49,7 +47,7 @@ def _mirror_recomposers() -> dict[str, Callable[[Note], dict]]:
}
# Text fields where EMPTY MEANS NULL (milestone 317). The sweep's whole signal
# Text fields where EMPTY MEANS NULL. The sweep's whole signal
# is `verify_with IS NULL` = "this is a decision, there is nothing to go and
# check". An empty string that is not NULL makes a norm look like a constraint
# nobody has verified, forever — and it would sit at the top of the sweep,
@@ -64,7 +62,7 @@ def guard_check_fields(status: str | None, note_type: str | None) -> None:
which fields a caller passed, so it also catches the sideways route: a
checked note being turned into a task, which no per-field gate would see.
Raises rather than dropping silently, for minted_kind's reason (#3129) — a
Raises rather than dropping silently, for minted_kind's reason — a
silently-corrected write is the defect that reasoning exists to end, and a
caller putting a check on the wrong record has an idea an error corrects
and a default hides. Lives at the service, not either door, so REST and MCP
@@ -92,12 +90,11 @@ def embed_note(note) -> None:
"""Refresh a note's embedding, fire-and-forget.
Lives HERE — at the service, not the route — so every caller gets it by
construction. Previously each REST route made this call itself and the MCP
tools did not, so a record created through MCP stayed out of semantic search
and auto-inject until the next restart's backfill ran (#2056). That is
invisible on an instance that redeploys constantly and permanent on one that
doesn't, which is the worst shape a bug can have: it only appears where
nobody is looking.
construction. A door that forgot the call would leave its records out of
semantic search and auto-inject until the next restart's backfill ran —
invisible on an instance that redeploys constantly and permanent on one
that doesn't, which is the worst shape a bug can have: it only appears
where nobody is looking.
Uses `note.user_id` — the OWNER — rather than the caller. Embeddings belong
to the record, and a collaborator editing a shared note must refresh the
@@ -110,16 +107,16 @@ def embed_note(note) -> None:
Detaching also means this task races anything that deletes the note out
from under it. That is not handled here: `upsert_note_embedding` claims
the note's row before touching its vectors, and loses if it can't (#3262).
the note's row before touching its vectors, and loses if it can't.
"""
try:
import asyncio
from scribe.services.embeddings import document_title, upsert_note_embedding
# Chunking and the empty-record gate live inside upsert_note_embedding —
# one path for every writer (#280). The title is the EMBEDDED one: a
# one path for every writer. The title is the EMBEDDED one: a
# snippet's or lesson's trigger joins its name here, not in the stored
# title (milestone 427).
# title.
asyncio.create_task(
upsert_note_embedding(
note.id, note.user_id,
@@ -305,7 +302,7 @@ def build_note(
expires_when=expires_when,
)
# A create that names a status is the same transition an update to it is
# (#3683) — otherwise `create_task(status="in_progress")` writes a row no
# — otherwise `create_task(status="in_progress")` writes a row no
# update could produce: started, with no `started_at`.
if status is not None:
apply_status_transition(note, user_id)
@@ -318,11 +315,11 @@ def apply_status_transition(note: Note, user_id: int | None = None) -> None:
Called by the update path whenever `status` is written and by `build_note`
whenever a create names one, so a task created at a status is
indistinguishable from one that reached it by update. Two copies of "what
a status implies" are where the two drift, and #3683 was that drift.
a status implies" are where the two drift.
`user_id` is whoever is making the change: reaching `in_progress` is the
moment a session takes the work on, so it stamps that user's claim, and a
status that ends or un-starts the work releases it (milestone 381).
status that ends or un-starts the work releases it.
"""
from scribe.services.task_claims import release_claim, stamp_claim
@@ -382,15 +379,14 @@ async def list_notes(
) -> tuple[list[Note], int]:
"""Lifecycle-shaped listing. Two contracts worth knowing:
VISIBILITY is the shared browse clause (#47, #2462): the caller's own
records plus anything in a project they can reach — the same reach
query_knowledge has always had. Before this, list_notes was silently
owner-only, so a task in a shared project was invisible in list_tasks,
VISIBILITY is the shared browse clause: the caller's own records plus
anything in a project they can reach — the same reach query_knowledge has.
Owner-only here would hide a task in a shared project from list_tasks,
enter_project's open-task list and the web UI while the same project's
notes appeared. Browse, not read, per decision #2094: an ambient list must
never surface a record someone shared one-to-one — those stay search-only.
notes appeared. Browse, not read: an ambient list must never surface a
record someone shared one-to-one — those stay search-only.
`q` is SEMANTIC (operator decision, 2026-08-06: "make it match") — the same
`q` is SEMANTIC — the same
meaning-based match as Browse search, at the interactive floor. When `q` is
present, relevance ordering wins and `sort` is ignored; a query is a
relevance claim and sorting its results by date would shuffle the answer.
@@ -430,9 +426,9 @@ async def list_notes(
if query_vec is not None:
from scribe.models.embedding import NoteEmbedding
from scribe.services.embeddings import INTERACTIVE_SEARCH_THRESHOLD
# Best-chunk-per-note as a correlated MIN, not a join (#280):
# a note stores one embedding row PER CHUNK, so the plain join
# this used to be would repeat a long note once per matching
# Best-chunk-per-note as a correlated MIN, not a join:
# a note stores one embedding row PER CHUNK, so a plain join
# would repeat a long note once per matching
# chunk — duplicated list rows and a total that counts chunks.
# This query is filter-heavy and paginated, never HNSW-bound,
# so the scalar subquery costs what the join did.
@@ -528,7 +524,7 @@ async def list_notes(
elif sort == "touched":
# Last touched: the later of the task's own edit and its newest
# work-log. Logging doesn't bump updated_at, so a task worked
# through its logs would otherwise sort as untouched (#4045).
# through its logs would otherwise sort as untouched.
# GREATEST skips a NULL, so a task with no logs sorts by its edit.
from scribe.models.task_log import TaskLog
last_log = (
@@ -589,8 +585,8 @@ def minted_kind(kind: str) -> str:
list is how the two doors would come to disagree.
Raises rather than falling back to 'work'. A silently-corrected kind is
the defect this exists to end (#3129: the editor's Kind select reported
success and changed nothing), and a caller naming a kind we do not know
the defect this exists to end (an editor that reports success and changes
nothing), and a caller naming a kind we do not know
has a wrong idea that an error corrects and a default hides.
"""
if kind in MINTABLE_KINDS:
@@ -616,7 +612,7 @@ async def update_note(
the one form that cannot happen by accident. The REST door, where a
cleared form input arrives as "", reaches the same place through the
NULLABLE_NOTE_TEXT normalisation below — two idioms, one outcome.
(Same shape as rulebooks.update_rule, milestone 312 step 2.)
(Same shape as rulebooks.update_rule.)
"""
async with async_session() as session:
result = await session.execute(
@@ -650,9 +646,7 @@ async def update_note(
# Same shape as status/priority above, and for the same
# reason: a kind the column will refuse should fail here with
# a readable message, not as a CheckViolationError from the
# database. Before this, `task_kind` reached setattr through
# the hasattr guard with no validation at all — but no door
# ever offered it, so a task's kind was write-once (#3129).
# database.
try:
value = TaskKind(value).value
except ValueError:
@@ -684,12 +678,12 @@ async def update_note(
if note.verify_with != check_before:
note.verified_at = None
# Some kinds derive `data` from their body — so a write that moves the
# body through this generic door must move the mirror with it (#3128).
# Without this, PATCH /api/notes/<id> {body} left the mirror behind,
# and every read PREFERS the mirror: a snippet went on reporting its
# body through this generic door must move the mirror with it.
# Without this, PATCH /api/notes/<id> {body} would leave the mirror
# behind, and every read PREFERS the mirror: a snippet would report its
# old repo/path/symbol to prior-art recall while showing its new body,
# and a lesson would go on being retrieved for the situation it used to
# name. The kind's own updater composes the mirror itself and passes it
# and a lesson would be retrieved for the situation it used to name.
# The kind's own updater composes the mirror itself and passes it
# explicitly, so an explicit `data` always wins — the caller that knows
# the field set beats the one that can only re-read the body.
if "data" not in fields and not _PARSED_FROM_BODY.isdisjoint(fields):
@@ -715,22 +709,21 @@ async def update_note(
return note
# A hard `delete_note(user_id, note_id)` lived here with ZERO callers, and was
# removed with #278 step 1. It is recorded rather than silently dropped because
# the danger was never that it ran — it is that it was findable by name. Someone
# wanting to delete a note greps `delete_note`, finds a function in the notes
# service with exactly the right signature, and permanently destroys a record
# every path downstream expects to be recoverable.
# There is deliberately no hard `delete_note(user_id, note_id)` here. The
# danger of one is not that it runs — it is that it is findable by name.
# Someone wanting to delete a note greps `delete_note`, finds a function in the
# notes service with exactly the right signature, and permanently destroys a
# record every path downstream expects to be recoverable.
#
# The delete path is `trash_svc.delete`, which soft-deletes an entity AND its
# descendants under one batch_id so `restore(batch)` works. `purge_trash` owns
# permanent deletion. Both are reachable; neither is spelled `delete_note`.
# ── The sweep (milestone 317 step 3) ─────────────────────────────────────────
# ── The sweep ────────────────────────────────────────────────────────────────
#
# A SIBLING of rulebooks.rules_due_for_verification, not a shared
# implementation, and deliberately so (note 3163). The row could have been
# implementation, and deliberately so. The row could have been
# shared; the QUERY cannot. That sweep scopes by rulebook ownership XOR project
# ownership because rules have no sharing ACL at all — no rule_shares, no
# can_read_rule. A note scopes by the note ACL, which is a different question
@@ -760,12 +753,12 @@ async def notes_due_for_verification(
decisions, there is nothing to go and check, and listing them would dilute
the result until nobody reads it.
Scoped with `browsable_notes_clause`, NOT the read scope (decision note
2094): a sweep is a passive surface, and a record shared one-to-one must
Scoped with `browsable_notes_clause`, NOT the read scope: a sweep is a
passive surface, and a record shared one-to-one must
not arrive in one unasked.
Deliberately NOT filtered to non-task, non-snippet records even though the
write path (step 2) permits a check on nothing else. A row in that state
write path permits a check on nothing else. A row in that state
would be a row in an ILLEGAL state, and this is the one surface that could
tell somebody about it. Hiding it here to match the invariant would make
the sweep agree with a database it had stopped describing.
@@ -942,10 +935,10 @@ async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[d
"""Resolve a stored process by id or name.
note_type='process', non-trashed, scoped to what this user may READ — owned
plus shared (rule #78). Naming one is an explicit act, so a Process shared
with the caller resolves here even though it is deliberately absent from the
passive process list and the skill manifest (decision note 2094); without
this, a Process a search surfaced could not then be run — see #2093.
plus shared (services/access.py). Naming one is an explicit act, so a
Process shared with the caller resolves here even though it is
deliberately absent from the passive process list and the skill manifest;
without this, a Process a search surfaced could not then be run.
Precedence: numeric id → exact case-insensitive title → substring. Returns
(note, other_candidates); on a substring tie with no exact hit, `note` is the
@@ -1118,16 +1111,15 @@ async def get_note_for_user(
return (note, perm) if note else None
# What a LISTING row carries (#4061): what the record is and where it sits,
# never what it says. list_tasks once returned every row's to_dict(), body
# included, and a project's todo list came to 93-165k characters, past what an
# MCP client accepts inline, so the list arrived as a file to page through. The
# same failure #4045 fixed for enter_project, one call over. get_task / get_note
# read a record in full; a list is for choosing which one to open.
# What a LISTING row carries: what the record is and where it sits, never what
# it says. Every row's to_dict(), body included, can bring a project's todo
# list past what an MCP client accepts inline, so the list arrives as a file to
# page through. get_task / get_note read a record in full; a list is for
# choosing which one to open.
#
# A field most rows leave empty (a one-line description, a parent, a due date)
# is attached only when set: a hundred rows of `null` are a hundred chances to
# learn to skip the key (#2483), and the bytes are the thing being cut.
# learn to skip the key, and the bytes are the thing being cut.
def brief_row(
note: Note,
milestone_titles: dict[int, str] | None = None,
+61 -87
View File
@@ -1,81 +1,60 @@
"""Rule usage telemetry — did a surfaced rule ever get read?
The sibling of `note_usage`, for the one retrieval surface in Scribe that
could not be measured at all.
The sibling of `note_usage`, for rules.
Two event streams, deliberately independent:
Event streams, deliberately independent:
- SURFACED: the write-path standing-rule arm put this rule in front of the
agent, unbidden, during a write.
- SURFACED: a surface put this rule in front of the agent — a ranked arm,
unbidden, or a bulk delivery (see AMBIENT VS RANKED below).
- PULLED: someone then opened it in full (`get_rule`, or the REST detail
route).
WHY THIS ARM AND NOT ANOTHER. Every other surface declines most of the time —
`write_path` returns nothing on 78% of calls, `reuse_slot` on 79%, auto-inject
on 39%. The rule arm APPEARED never to have returned nothing (#3311), and this
docstring used to put that forward as the puzzle worth measuring: "either a
perfectly tuned surface or a bar it cannot fail to clear".
It was neither, and the correction belongs here rather than being quietly
deleted. The arm wrote its `retrieval_logs` row only on calls that FOUND
something (#3497), so `zero_result_calls` sat at 0 and `cleared_threshold` at
`calls` because of the shape of the code — at any threshold whatsoever. A
statistic that could not vary was read as a finding about the corpus. It is the
#2663 failure mode one level up: there the broken readout was a zero, here it
was a hundred percent, which is far better camouflage.
The reason to measure this arm survives the correction, and is stronger for it.
`retrieval_logs` records what the ranker scored, never whether the hint was any
use, so even an honest clear-rate would not settle the question. The ratio these
two streams produce is the missing half, and without it any threshold change is
a number picked off a histogram.
WHY RULES NEED THEIR OWN STREAM. `retrieval_logs` records what the ranker
scored, never whether the hint was any use, so even an honest clear-rate cannot
settle whether a rule arm earns its lines. The ratio these two streams produce
is the missing half, and without it any threshold change is a number picked
off a histogram. (A clear-rate can also mislead outright: an arm that logs
only the calls that FOUND something reports a decline count of zero at any
threshold — a statistic that cannot vary, read as a finding about the corpus.)
Design notes, mirroring `note_usage`:
- Writes are fire-and-forget through `background.spawn`, so telemetry never
adds latency to — or can break — the surface it observes. This module does
NOT carry its own copy of the strong-reference dance; `background` is the
one place that gets it right, and a fourth copy is how one of them drifts.
one place that gets it right, and another copy is how one of them drifts.
- Failures degrade, but never SILENTLY. `report_telemetry_failure` logs at
WARNING and drops one AppLog row per process per site. #2663 is the record
of this exact subsystem class running at zero for weeks — indistinguishable
from "nobody uses this" — because every failure went to `logger.debug`.
WARNING and drops one AppLog row per process per site. A telemetry
subsystem whose failures go to `logger.debug` can run at zero for weeks,
indistinguishable from "nobody uses this".
- Reads (`usage_for_rules`) are awaited and aggregated in one round-trip for
a whole page, never per row.
AMBIENT VS RANKED. The note twin splits ranked surfacings from ambient ones
because `enter_project` and the skill sync put records in front of the agent
without choosing them, and counting those as surfacings makes recency read as
popularity (#2477). Rules have exactly that shape: the SessionStart preload,
and every `rules_payload` surface hand over the whole
applicable set at once, chosen by nobody.
popularity. Rules have exactly that shape: the SessionStart preload, and every
`rules_payload` surface hand over the whole applicable set at once, chosen by
nobody.
Until 2026-09-03 those bulk surfaces emitted nothing, and this module said so —
"an empty `AMBIENT_SOURCES` would be machinery pretending to a distinction the
data does not yet contain". True as far as it went, but it had a consequence
worth naming, because it is the reason the bucket exists now: the always-on
set's token cost was certain and its usefulness was UNFALSIFIABLE, permanently
and by construction. The one surface whose value was actually in question was
the one surface exempt from the scoreboard that judges every other.
They emit now. The split is the readout-level change the old note promised — a
`case()`, no migration, because `event` and `source` are plain Text with no
CHECK constraint. `source` stays granular so a reader can still tell the
preload from `enter_project` from the ranked arm.
Those bulk surfaces emit too, as ambient. Without them the always-on set's
token cost would be certain and its usefulness UNFALSIFIABLE — the one surface
whose value is actually in question would be the one exempt from the
scoreboard that judges every other. The split is a readout-level `case()`, no
migration, because `event` and `source` are plain Text with no CHECK
constraint. `source` stays granular so a reader can still tell the preload
from `enter_project` from a ranked arm.
WHY THIS NAMES THE RANKED SOURCES AND THE TWIN NAMES THE AMBIENT ONES. A
deliberate divergence, on the failure mode rather than on symmetry. Both shapes
fail silently when someone adds a surface and forgets the list, so the question
is which list changes more often — and here it is emphatically the ambient one:
there are TWO ranked rule sources (the write-path arm and the pre-tool arm)
against the seven bulk ones the preload alone contributes. Ranked sources are
added when somebody builds a ranker, which is rare and deliberate; bulk ones
appear whenever a surface hands rules over, which is most of them. Naming the
rare, slow-moving half means a newly-added bulk surface defaults to
is which list changes more often — and here it is the ambient one. Ranked
sources are added when somebody builds a ranker, which is rare and deliberate;
bulk ones appear whenever a surface hands rules over, which is most of them.
Naming the rare, slow-moving half means a newly-added bulk surface defaults to
`ambient`, which merely under-counts it, instead of defaulting to `ranked`,
which would quietly pad the pull-through denominator with surfacings nobody
chose and make the arm look imprecise. Same argument #3191 and #3430 make
against hand-kept lists: keep the list that must be remembered as short and as
slow-moving as possible.
chose and make the arm look imprecise. Keep the list that must be remembered
as short and as slow-moving as possible.
"""
from __future__ import annotations
@@ -99,15 +78,15 @@ logger = logging.getLogger(__name__)
# Membership is the whole definition of the pull-through denominator: a ranked
# surfacing is a claim ("this rule may apply to what you are doing") that a pull
# can confirm or refute, while an ambient one is a delivery nobody decided on.
# The ranked half is read off the retrieval pipeline's specs (milestone 456
# step 5): every rule arm — the reply backstop included, which records only
# The ranked half is read off the retrieval pipeline's specs: every rule arm
# — the reply backstop included, which records only
# the rule that HELD the reply — the preference slot, which is a ranker's
# choice twice over, and the rule reached through a confirmed lesson link,
# whose own source lets its pull-through read apart from a direct match
# (#4636). An arm added there is counted here by construction.
# whose own source lets its pull-through read apart from a direct match.
# An arm added there is counted here by construction.
RANKED_SOURCES = (
*RULE_RANKED_SOURCES,
# A rule mounted on a moment (milestone 458). Not a ranker's pick, but
# A rule mounted on a moment. Not a ranker's pick, but
# not bulk either: somebody decided this rule applies at this moment, and
# that is exactly the claim a pull can confirm or refute. Its
# pull-through is the evidence for whether a mount earns its line.
@@ -119,11 +98,10 @@ def is_ambient(source: str) -> bool:
"""Was this surfacing a bulk delivery rather than a ranked choice?
One definition, read by both the per-rule badge readout and the aggregate
in `retrieval_telemetry` — the two used to be able to disagree about what
"surfaced" counted, which is the class of drift #3246 found across the
rules system.
in `retrieval_telemetry`, so the two cannot disagree about what
"surfaced" counts.
Sync and pure, per the service canon (#2860), but deliberately PUBLIC where
Sync and pure, per the service canon, but deliberately PUBLIC where
that canon says such helpers stay `_private`. The departure is the point:
a module-private copy in each caller is exactly the second definition this
exists to prevent.
@@ -174,7 +152,7 @@ def record_rule_surfaced(
care which kind it is recording.
`detail` maps a rule id to what to record beside its row — the moment a
mounted rule arrived at (milestone 458), so a mount's pull-through can be
mounted rule arrived at, so a mount's pull-through can be
read per moment rather than only per arm.
"""
try:
@@ -225,14 +203,13 @@ def record_rule_outcome(
source: str,
detail: str = "",
) -> None:
"""Fire-and-forget: record what a rule ACTUALLY CHANGED (#4212).
"""Fire-and-forget: record what a rule ACTUALLY CHANGED.
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.
The third stream. `surfaced` says the system offered a rule; `pulled`
says somebody opened it. Neither says whether it made any difference, so
without this a rule that fires constantly and is always obeyed and a rule
that fires constantly and is never obeyed produce identical telemetry.
The second is far the more urgent.
Two outcomes, because there are only two a judge can honestly report:
@@ -245,14 +222,14 @@ def record_rule_outcome(
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
unchanged is real — it is the failure this stream exists to expose — 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
telemetry that is missing, and a `departed` row with an empty
reason is a lie the readout cannot detect.
"""
if outcome not in OUTCOMES:
@@ -280,9 +257,8 @@ def record_rule_outcome(
# 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.
# ordered by how much the system actually knows; recorded outcomes are what
# keep UNACTED from hiding inside FOLLOWED.
UNREAD = "unread" # surfaced, never opened
UNACTED = "unacted" # opened, and nothing recorded after — the blind spot
FOLLOWED = "followed" # opened and applied
@@ -290,13 +266,12 @@ 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.
"""Which of the four states a rule's usage is in.
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.
counts would drift.
PRECEDENCE, and it is deliberate: a departure outranks an application.
A rule both applied and departed from in the same window is a rule
@@ -306,8 +281,8 @@ def outcome_state(usage: dict) -> str:
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.
than never having been opened at all, and without recorded outcomes it
is indistinguishable from compliance.
"""
if int(usage.get("departed_count") or 0):
return DEPARTED_FROM
@@ -338,9 +313,9 @@ 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.
# The outcome half. 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 (UNACTED).
# `outcome_state` is what tells those apart; no caller should be
# reading these counts raw to decide it.
#
@@ -349,7 +324,7 @@ def empty_rule_usage() -> dict:
# 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).
# prose reads the rows.
"applied_count": 0,
"departed_count": 0,
"last_outcome_at": None,
@@ -377,9 +352,8 @@ async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
# GROUP BY — a second `case()` instance there renders its own expanding-IN
# bind names under asyncpg, so the database sees two DIFFERENT expressions
# and rejects the query with a GroupingError. The note twin carries the
# same warning for the same reason, and #2663 is what it cost: the
# rejection was swallowed and every counter read zero in production while
# the writes were landing fine.
# same warning for the same reason: the rejection is swallowed and every
# counter reads zero while the writes are landing fine.
ambient = case(
(RuleUsageEvent.source.notin_(RANKED_SOURCES), True),
else_=False,
@@ -406,7 +380,7 @@ async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
except Exception:
# A telemetry readout must not be able to break the list it decorates —
# but it must say it failed, or a broken readout is indistinguishable
# from a corpus nobody uses (#2663).
# from a corpus nobody uses.
await _report_failure("readout")
return out