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" SURFACED = "surfaced"
PULLED = "pulled" PULLED = "pulled"
# The outcome half (#4212, milestone 419). A rule that was read and then # The outcome half. Without these, a rule that was read and then ignored is
# ignored has always been indistinguishable from one that was read and # indistinguishable from one that was read and obeyed; these are the two
# obeyed; these are the two events that can tell them apart. # events that can tell them apart.
# #
# There is deliberately NO third value for "read and ignored". That state is # 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: # real and is the point of measuring outcomes, but it cannot be reported: an
# an agent that knew it was ignoring a rule would not be ignoring it. It is # 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 # 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 # nothing while reading as though it had measured something.
# failure exactly.
APPLIED = "applied" APPLIED = "applied"
DEPARTED = "departed" DEPARTED = "departed"
OUTCOMES = (APPLIED, 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 """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. ACTED ON — applied, or departed from with a stated reason.
The sibling `note_usage_events` has had since 2026-07, third in the line The rule-side sibling of `note_usage_events`, beside `rule_embeddings`
after `rule_embeddings` and `rule_versions` — and, like those, it exists and `rule_versions` — each exists because machinery built for notes gives
because the rule side kept inheriting machinery built for notes and rules the weaker version of it.
quietly getting the weaker version of it.
WHY RULES NEED THEIR OWN AND CANNOT SHARE THE NOTE TABLE. Not squeamishness 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 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 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 note id and a rule id are different namespaces resolved through different
maps, and `note_usage_events`'s importer maps `note_id` through 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 `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, happened to take that number — telemetry that is not merely lost but wrong,
and wrong in a way nothing downstream could detect. and wrong in a way nothing downstream could detect.
WHAT THIS MEASURES, AND WHY IT DID NOT EXIST. The write-path standing-rule WHAT THIS MEASURES. A standing-rule arm's usefulness cannot be observed
arm is the only retrieval surface in Scribe whose usefulness cannot be from its scores: an arm can fire on every call, clearing its threshold
observed — and, not coincidentally, the only one that has never declined to every time, and say nothing about whether it helped. `retrieval_logs`
fire (#3311: 296 calls, zero zero-result, 100% clearing its threshold). gives it scores; scores say what the ranker thought, never
`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 whether the hint landed. Without a pull counter no install can tune the arm
from evidence, only from the shape of a histogram. 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 forty times and opened never, because that evidence is precisely the case
for having deleted it. 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, 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. 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' # 'surfaced' | 'pulled' | 'applied' | 'departed'
# #
# Plain Text with no CHECK, as created in 0094 — which is why 0106 added # 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 # 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 # the migration because this is where the next person adding a value will
# look first. # look first.
@@ -84,9 +81,9 @@ class RuleUsageEvent(Base, CreatedAtMixin):
# Which surface produced it. A CONVENTION, not a fixed vocabulary, and the # Which surface produced it. A CONVENTION, not a fixed vocabulary, and the
# note twin's comment explains why this one deliberately does not enumerate # note twin's comment explains why this one deliberately does not enumerate
# its members: the previous such list went stale, naming a source nothing # its members: such a list goes stale, naming a source nothing writes while
# wrote while omitting ones that existed, and a half-true enumeration reads # omitting ones that exist, and a half-true enumeration reads as
# as authoritative in exactly the way that misleads (#2476). # authoritative in exactly the way that misleads.
# `grep -rn record_rule_pulled\|record_rule_surfaced src/` is the # `grep -rn record_rule_pulled\|record_rule_surfaced src/` is the
# authoritative list, and unlike a comment it cannot drift. # 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 # 403, not 400: the request is well-formed and the caller simply
# may not write the target. The note itself was created. # may not write the target. The note itself was created.
return jsonify({"error": str(exc), "note": note.to_dict()}), 403 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 # Both doors file a note to Systems: the door nobody exercises for a kind
# MCP door could — the reverse of the asymmetry the same issue records for # is the one that never grows the parameter.
# 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.
if data.get("system_ids") is not None: if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, note.id, data["system_ids"]) await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
out = note.to_dict() 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. # join away, but which surface asked is not recoverable after the fact.
# Keeping rest_* apart from mcp_* is load-bearing, not tidiness — "was that # Keeping rest_* apart from mcp_* is load-bearing, not tidiness — "was that
# injected line useful?" is answered by agent pulls alone, and a human # 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") record_pulled(user_id=uid, note_id=note_id, source="rest_note")
await supersession_svc.attach_relations(uid, note_id, data) await supersession_svc.attach_relations(uid, note_id, data)
return jsonify(data) return jsonify(data)
@@ -246,8 +244,8 @@ async def get_note_route(note_id: int):
async def update_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 """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 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 edited, and two copies of this function would drift (one carrying the
function that drifted (one carried the supersedes contract, one did not).""" supersedes contract, one not)."""
uid = get_current_user_id() uid = get_current_user_id()
# Share-aware: resolve through the ACL and write as the OWNER, so a shared # 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. # 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", "milestone_id", "status", "priority", "note_type",
# A cleared form input arrives as "" and the service reads that as # A cleared form input arrives as "" and the service reads that as
# NULL (NULLABLE_NOTE_TEXT), so this door expresses "remove the check" # 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", "verify_with", "expires_when",
): ):
if key in data: if key in data:
@@ -298,12 +296,10 @@ async def update_note_route(note_id: int):
return jsonify({"error": str(exc)}), 403 return jsonify({"error": str(exc)}), 403
# Set-semantics like the above: present-and-empty clears, absent leaves # Set-semantics like the above: present-and-empty clears, absent leaves
# alone. Scoped by the CALLER rather than owner_uid, for the same reason # 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 supersedes call is — `set_record_systems` links only Systems the
# the acting user can read, and an editor-share holder should tag from # acting user can read, and an editor-share holder should tag from what
# what they can see rather than inherit the owner's reach. This matches # they can see rather than inherit the owner's reach. Every door that
# routes/tasks.py; routes/lessons.py and routes/snippets.py pass the owner # tags a record (tasks, lessons, snippets) does the same.
# for the same operation, which is a real inconsistency, recorded in #4249
# rather than changed here.
if "system_ids" in data: if "system_ids" in data:
await systems_svc.set_record_systems(uid, note_id, data["system_ids"] or []) await systems_svc.set_record_systems(uid, note_id, data["system_ids"] or [])
out = note.to_dict() out = note.to_dict()
@@ -521,7 +517,7 @@ async def graph_route():
return jsonify(graph) return jsonify(graph)
# ── The staleness sweep (milestone 317) ────────────────────────────────────── # ── The staleness sweep ──────────────────────────────────────────────────────
# The web half of notes_due_for_verification / mark_note_verified. Same # 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 # contract as the MCP door and the rules routes beside it — the service holds
# the behaviour, these two just parse and serialise. # 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 WHY THIS EXISTS SEPARATELY FROM /api/settings
The numbers themselves are ordinary settings rows and could be written through 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 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 blueprint: a floor changed through `/api/settings` moves silently, leaving the
history saying nothing happened. From milestone 416 those dials are moved by history saying nothing happened. Those dials are moved by the model on the
the model on the operator's behalf, so a trail with holes in it is worse than operator's behalf, so a trail with holes in it is worse than
no trail — it reads as complete. no trail — it reads as complete.
So every change to a retrieval dial goes through `set_dial`, from the UI as 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 # 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 # written for, they cannot be uninformed about their own decision, and a
# mandatory justification textarea on every control would be friction charged # 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 # 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 # 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"]) @retrieval_bp.route("/moments", methods=["GET"])
@login_required @login_required
async def moments_route(): 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. actions that reach each one on this install.
The same payload `list_moments` returns, from the same services, so the The same payload `list_moments` returns, from the same services, so the
session and the Settings view cannot name different moments — plus what 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` carries, and `usage`, the deliveries and opens per moment over `days`
(the block `retrieval_telemetry` reports as `moment_usage`). (the block `retrieval_telemetry` reports as `moment_usage`).
""" """
@@ -147,11 +147,11 @@ async def moments_route():
@retrieval_bp.route("/reply-shapes", methods=["GET"]) @retrieval_bp.route("/reply-shapes", methods=["GET"])
@login_required @login_required
async def reply_shapes_route(): 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 `list_reply_shapes`' payload from the same service, so the Settings view
and the session cannot show different defaults — plus what only the 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). moment and its deliveries over `days` (default 30, 1–90).
""" """
try: try:
@@ -201,7 +201,7 @@ async def unmap_action_route():
@retrieval_bp.route("/moments/proposals", methods=["GET"]) @retrieval_bp.route("/moments/proposals", methods=["GET"])
@login_required @login_required
async def moment_proposals_route(): 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=` `rule_moment_proposals`' payload, from the same service. `?rule_id=`
narrows to one rule, for the rule editor.""" narrows to one rule, for the rule editor."""
try: 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 agent-facing surface. Both go through services/snippets.py, so the
serialize/parse contract and embedding-on-create live in one place (DRY). 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 `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 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. 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", "") repo = request.args.get("repo", "")
path = request.args.get("path", "") path = request.args.get("path", "")
symbol = request.args.get("symbol", "") 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", "") verification = request.args.get("verification", "")
limit, offset = parse_pagination() limit, offset = parse_pagination()
items, total = await snippets_svc.list_snippets( items, total = await snippets_svc.list_snippets(
@@ -115,7 +115,7 @@ async def create_snippet_route():
is_task=False, is_task=False,
note_type=snippets_svc.SNIPPET_NOTE_TYPE, note_type=snippets_svc.SNIPPET_NOTE_TYPE,
# Matched on the artefact — location and code — before prose, the # 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 # apply the identical gate or the web UI becomes the way to record
# a duplicate the agent would have been stopped from writing. # a duplicate the agent would have been stopped from writing.
code=data.get("code", ""), code=data.get("code", ""),
@@ -163,7 +163,7 @@ async def get_snippet_route(snippet_id: int):
return not_found("Snippet") return not_found("Snippet")
note, permission = loaded note, permission = loaded
data = snippets_svc.snippet_to_dict(note) 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) await snippets_svc.attach_live_body(note, data)
data["permission"] = permission data["permission"] = permission
# Read the association as the OWNER: a shared reader isn't scoped to the # 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]) await attach_usage([data])
# Opening the detail view IS a pull — the operator chose to look. Tagged # 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" # 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") record_pulled(user_id=uid, note_id=snippet_id, source="rest_snippet")
return jsonify(data) return jsonify(data)
@@ -212,7 +212,7 @@ async def update_snippet_route(snippet_id: int):
if updated is None: if updated is None:
return not_found("Snippet") return not_found("Snippet")
if data.get("system_ids") is not None: 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"]) await systems_svc.set_record_systems(uid, snippet_id, data["system_ids"])
out = snippets_svc.snippet_to_dict(updated) out = snippets_svc.snippet_to_dict(updated)
out["systems"] = [ out["systems"] = [