docs(6206): retrieval, snippets, notes routes and rule_usage model drop citations

Corrected while reading: routes/notes.py said lessons and snippets pass the
owner to set_record_systems, "a real inconsistency". Every door passes the
caller; the comment now says so.

rule_usage's model docstring states what an outcome measures and why the
row cannot share the note table, without the measurement and record ids
that motivated it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:41:13 -04:00
co-authored by Claude Opus 5.5
parent 6a9f02990c
commit 78e1bdbd93
4 changed files with 45 additions and 52 deletions
+19 -22
View File
@@ -7,16 +7,15 @@ 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.
# The outcome half. Without these, a rule that was read and then ignored is
# 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
# real and is the point of measuring outcomes, 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.
# nothing while reading as though it had measured something.
APPLIED = "applied"
DEPARTED = "departed"
OUTCOMES = (APPLIED, DEPARTED)
@@ -26,15 +25,14 @@ 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.
The rule-side sibling of `note_usage_events`, beside `rule_embeddings`
and `rule_versions` — each exists because machinery built for notes gives
rules 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
sharing there is. 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
@@ -42,11 +40,10 @@ class RuleUsageEvent(Base, CreatedAtMixin):
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
WHAT THIS MEASURES. A standing-rule arm's usefulness cannot be observed
from its scores: an arm can fire on every call, clearing its threshold
every time, and say nothing about whether it helped. `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.
@@ -58,7 +55,7 @@ class RuleUsageEvent(Base, CreatedAtMixin):
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
Cells left deliberately empty: 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.
"""
@@ -76,7 +73,7 @@ class RuleUsageEvent(Base, CreatedAtMixin):
# '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
# the outcome pair without a DROP/ADD migration — that step is for
# 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.
@@ -84,9 +81,9 @@ class RuleUsageEvent(Base, CreatedAtMixin):
# 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).
# its members: such a list goes stale, naming a source nothing writes while
# omitting ones that exist, and a half-true enumeration reads as
# authoritative in exactly the way that misleads.
# `grep -rn record_rule_pulled\|record_rule_surfaced src/` is the
# authoritative list, and unlike a comment it cannot drift.
#
+11 -15
View File
@@ -131,10 +131,8 @@ async def create_note_route():
# 403, not 400: the request is well-formed and the caller simply
# may not write the target. The note itself was created.
return jsonify({"error": str(exc), "note": note.to_dict()}), 403
# #4249: this door could not file a note to a System at all, while the
# MCP door could — the reverse of the asymmetry the same issue records for
# lessons. The pattern is not that one door is richer; it is that the door
# nobody exercised for a kind is the one that never grew the parameter.
# Both doors file a note to Systems: the door nobody exercises for a kind
# is the one that never grows the parameter.
if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
out = note.to_dict()
@@ -235,7 +233,7 @@ async def get_note_route(note_id: int):
# join away, but which surface asked is not recoverable after the fact.
# Keeping rest_* apart from mcp_* is load-bearing, not tidiness — "was that
# injected line useful?" is answered by agent pulls alone, and a human
# clicking a link would inflate exactly the number #1038 and #2085 gate on.
# clicking a link would inflate exactly the number pruning gates on.
record_pulled(user_id=uid, note_id=note_id, source="rest_note")
await supersession_svc.attach_relations(uid, note_id, data)
return jsonify(data)
@@ -246,8 +244,8 @@ async def get_note_route(note_id: int):
async def update_note_route(note_id: int):
"""Partial update — only the keys present in the payload change. PUT and
PATCH are the same handler on purpose: the form sends the field set it
edited, and the two verbs used to be two near-identical copies of this
function that drifted (one carried the supersedes contract, one did not)."""
edited, and two copies of this function would drift (one carrying the
supersedes contract, one not)."""
uid = get_current_user_id()
# Share-aware: resolve through the ACL and write as the OWNER, so a shared
# editor's save isn't rejected by the owner-scoped update service.
@@ -265,7 +263,7 @@ async def update_note_route(note_id: int):
"milestone_id", "status", "priority", "note_type",
# A cleared form input arrives as "" and the service reads that as
# NULL (NULLABLE_NOTE_TEXT), so this door expresses "remove the check"
# with its own idiom and needs no `clear` list (milestone 317).
# with its own idiom and needs no `clear` list.
"verify_with", "expires_when",
):
if key in data:
@@ -298,12 +296,10 @@ async def update_note_route(note_id: int):
return jsonify({"error": str(exc)}), 403
# Set-semantics like the above: present-and-empty clears, absent leaves
# alone. Scoped by the CALLER rather than owner_uid, for the same reason
# the supersedes call is (#47) — `set_record_systems` links only Systems
# the acting user can read, and an editor-share holder should tag from
# what they can see rather than inherit the owner's reach. This matches
# routes/tasks.py; routes/lessons.py and routes/snippets.py pass the owner
# for the same operation, which is a real inconsistency, recorded in #4249
# rather than changed here.
# the supersedes call is — `set_record_systems` links only Systems the
# acting user can read, and an editor-share holder should tag from what
# they can see rather than inherit the owner's reach. Every door that
# tags a record (tasks, lessons, snippets) does the same.
if "system_ids" in data:
await systems_svc.set_record_systems(uid, note_id, data["system_ids"] or [])
out = note.to_dict()
@@ -521,7 +517,7 @@ async def graph_route():
return jsonify(graph)
# ── The staleness sweep (milestone 317) ──────────────────────────────────────
# ── The staleness sweep ──────────────────────────────────────────────────────
# The web half of notes_due_for_verification / mark_note_verified. Same
# contract as the MCP door and the rules routes beside it — the service holds
# the behaviour, these two just parse and serialise.
+9 -9
View File
@@ -1,12 +1,12 @@
"""The operator's view of retrieval tuning — what the dials are, and who moved them (#4102).
"""The operator's view of retrieval tuning — what the dials are, and who moved them.
WHY THIS EXISTS SEPARATELY FROM /api/settings
The numbers themselves are ordinary settings rows and could be written through
the generic KV endpoint. They must not be, and that is the whole point of this
blueprint: a floor changed through `/api/settings` moves silently, leaving the
history saying nothing happened. From milestone 416 those dials are moved by
the model on the operator's behalf, so a trail with holes in it is worse than
history saying nothing happened. Those dials are moved by the model on the
operator's behalf, so a trail with holes in it is worse than
no trail — it reads as complete.
So every change to a retrieval dial goes through `set_dial`, from the UI as
@@ -39,7 +39,7 @@ retrieval_bp = Blueprint("retrieval", __name__, url_prefix="/api/retrieval")
# behalf. The operator IS that someone: they are the audience the trail is
# written for, they cannot be uninformed about their own decision, and a
# mandatory justification textarea on every control would be friction charged
# to the one participant who owes no explanation (rule 24).
# to the one participant who owes no explanation.
#
# The event is still written, because "the operator set this by hand" is the
# single most useful thing the history can tell a later session — it is the one
@@ -116,12 +116,12 @@ async def tuning_history_route():
@retrieval_bp.route("/moments", methods=["GET"])
@login_required
async def moments_route():
"""The moments of work that rules mount on (milestone 458), with the
"""The moments of work that rules mount on, with the
actions that reach each one on this install.
The same payload `list_moments` returns, from the same services, so the
session and the Settings view cannot name different moments — plus what
the view shows beside each one (step 6): `mounted`, how many rules each
the view shows beside each one: `mounted`, how many rules each
carries, and `usage`, the deliveries and opens per moment over `days`
(the block `retrieval_telemetry` reports as `moment_usage`).
"""
@@ -147,11 +147,11 @@ async def moments_route():
@retrieval_bp.route("/reply-shapes", methods=["GET"])
@login_required
async def reply_shapes_route():
"""The default reply shapes and the moment each rides (milestone 500).
"""The default reply shapes and the moment each rides.
`list_reply_shapes`' payload from the same service, so the Settings view
and the session cannot show different defaults — plus what only the
operator's view needs (step 5): per shape, the preferences mounted on its
operator's view needs: per shape, the preferences mounted on its
moment and its deliveries over `days` (default 30, 1–90).
"""
try:
@@ -201,7 +201,7 @@ async def unmap_action_route():
@retrieval_bp.route("/moments/proposals", methods=["GET"])
@login_required
async def moment_proposals_route():
"""The moment proposals waiting on a judgment (milestone 458 step 7) —
"""The moment proposals waiting on a judgment —
`rule_moment_proposals`' payload, from the same service. `?rule_id=`
narrows to one rule, for the rule editor."""
try:
+6 -6
View File
@@ -5,7 +5,7 @@ routes feed the web management UI; the MCP tools (mcp/tools/snippets.py) are the
agent-facing surface. Both go through services/snippets.py, so the
serialize/parse contract and embedding-on-create live in one place (DRY).
ACL (rule #78): reads/writes of a single snippet resolve through the share-aware
ACL: reads/writes of a single snippet resolve through the share-aware
`get_note_for_user` + `can_write_note`, and writes are performed as the OWNER so
a shared editor isn't rejected by the owner-scoped service — mirroring
routes/notes.py. The list is owner-scoped, matching the note-browse surface.
@@ -65,7 +65,7 @@ async def list_snippets_route():
repo = request.args.get("repo", "")
path = request.args.get("path", "")
symbol = request.args.get("symbol", "")
# Drift check (#2086). "attention" is what the UI's filter chip sends.
# Drift check. "attention" is what the UI's filter chip sends.
verification = request.args.get("verification", "")
limit, offset = parse_pagination()
items, total = await snippets_svc.list_snippets(
@@ -115,7 +115,7 @@ async def create_snippet_route():
is_task=False,
note_type=snippets_svc.SNIPPET_NOTE_TYPE,
# Matched on the artefact — location and code — before prose, the
# same way the MCP create path does (#2518). Both surfaces must
# same way the MCP create path does. Both surfaces must
# apply the identical gate or the web UI becomes the way to record
# a duplicate the agent would have been stopped from writing.
code=data.get("code", ""),
@@ -163,7 +163,7 @@ async def get_snippet_route(snippet_id: int):
return not_found("Snippet")
note, permission = loaded
data = snippets_svc.snippet_to_dict(note)
# Forge-checked freshness (#2690) — same decoration the MCP pull gets.
# Forge-checked freshness — same decoration the MCP pull gets.
await snippets_svc.attach_live_body(note, data)
data["permission"] = permission
# Read the association as the OWNER: a shared reader isn't scoped to the
@@ -177,7 +177,7 @@ async def get_snippet_route(snippet_id: int):
await attach_usage([data])
# Opening the detail view IS a pull — the operator chose to look. Tagged
# apart from the MCP sources so "the agent reused it" and "a human read it"
# stay distinguishable; they mean different things for pruning (#2085).
# stay distinguishable; they mean different things for pruning.
record_pulled(user_id=uid, note_id=snippet_id, source="rest_snippet")
return jsonify(data)
@@ -212,7 +212,7 @@ async def update_snippet_route(snippet_id: int):
if updated is None:
return not_found("Snippet")
if data.get("system_ids") is not None:
# The CALLER, not owner_uid — see routes/lessons.py for the why (#4249).
# The CALLER, not owner_uid — see routes/lessons.py for the why.
await systems_svc.set_record_systems(uid, snippet_id, data["system_ids"])
out = snippets_svc.snippet_to_dict(updated)
out["systems"] = [