docs(6205): rulebooks comments state the reason, not the instance that taught it

Milestone, issue, note and rule-number citations are removed. The
co_surfaces docstring states why paired rules need the edge instead of
retelling the instance that taught it; the list-rules and applicable-rules
docstrings drop the retired subscription history. The preference-restore
error string no longer cites a milestone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:15:41 -04:00
co-authored by Claude Opus 5.5
parent 57773c8b55
commit 0a310f326f
+59 -70
View File
@@ -267,8 +267,8 @@ async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
# The vocabularies migration 0088's CHECK constraints enforce. Named here so # The vocabularies migration 0088's CHECK constraints enforce. Named here so
# a caller can be corrected before the database refuses it (rule 36 keeps the # a caller can be corrected before the database refuses it (a migration that
# two in step; this keeps the error readable). # widens one widens the other; this keeps the error readable).
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates") RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
# Migration 0098's CHECK. `rule` binds; `preference` is how the operator # Migration 0098's CHECK. `rule` binds; `preference` is how the operator
# wants work done — see the model comment for why both live on one table. # wants work done — see the model comment for why both live on one table.
@@ -303,23 +303,21 @@ def _valid_kind(kind: str) -> str:
return kind if kind in KINDS else "rule" return kind if kind in KINDS else "rule"
# Re-exported, not redefined. Notes gained the same trio in milestone 317 and # Re-exported, not redefined. Notes carry the same verification trio and this
# this reading of it is genuinely common, so it moved to services/verification # reading of it is common to both, so it lives in services/verification —
# — the DRY win note 3163 names, as against sharing the QUERY, which the two # sharing the reading, not the QUERY, which the two record types cannot share
# record types cannot (a rule scopes by rulebook ownership, a note by the note # (a rule scopes by rulebook ownership, a note by the note ACL). Importable
# ACL). Kept importable from here because callers already reach for it here. # from here because callers reach for it here.
last_verified_label = _last_verified_label last_verified_label = _last_verified_label
def rule_brief(rule: Rule, **extra) -> dict: def rule_brief(rule: Rule, **extra) -> dict:
"""The shape a rule takes when it is SURFACED rather than opened. """The shape a rule takes when it is SURFACED rather than opened.
One builder for every payload that hands rules to an agent, because there One builder for every payload that hands rules to an agent, because
were three copies of this dict and they had already diverged — two carried copies of this dict diverge. It carries the timestamps: without them a
`topic_id`, one didn't, and none carried the timestamps the model has held rule written before the capability it duplicates is indistinguishable, at
all along. That omission is why a rule written before the capability it read time, from one still doing work.
duplicates was indistinguishable, at read time, from one still doing work
(the FabledCurator case, note 3026).
`updated_at` is a DATE, not a stamp: the question it answers is "how old `updated_at` is a DATE, not a stamp: the question it answers is "how old
is this?", and a full ISO string across an always-on set is ~2k characters is this?", and a full ISO string across an always-on set is ~2k characters
@@ -343,8 +341,8 @@ def rule_brief(rule: Rule, **extra) -> dict:
"kind": rule.kind or "rule", "kind": rule.kind or "rule",
"updated_at": rule.updated_at.date().isoformat() if rule.updated_at else None, "updated_at": rule.updated_at.date().isoformat() if rule.updated_at else None,
} }
# Attached only when present (#2483: never a null key that reads as a # Attached only when present: never a null key that reads as a
# capability the record doesn't have). # capability the record doesn't have.
if rule.when_to_apply: if rule.when_to_apply:
out["when_to_apply"] = rule.when_to_apply out["when_to_apply"] = rule.when_to_apply
if rule.arose_from_id: if rule.arose_from_id:
@@ -353,7 +351,7 @@ def rule_brief(rule: Rule, **extra) -> dict:
# signal, and it says two things at once: this rule asserts a fact that # signal, and it says two things at once: this rule asserts a fact that
# can go false, and here is how long ago anyone confirmed it. The check # can go false, and here is how long ago anyone confirmed it. The check
# text itself stays in get_rule; a listing needs to know WHICH rules can # text itself stays in get_rule; a listing needs to know WHICH rules can
# rot, not how to test them. "never" rather than null, per #2483: a key # rot, not how to test them. "never" rather than null: a key
# that reads as an unused capability is a different claim from a rule # that reads as an unused capability is a different claim from a rule
# nobody has ever verified. # nobody has ever verified.
stamp = last_verified_label(rule) stamp = last_verified_label(rule)
@@ -373,7 +371,7 @@ def _refresh_rule_embedding(rule: Rule) -> None:
Detaching also means this task races anything that deletes the rule out Detaching also means this task races anything that deletes the rule out
from under it. That is not handled here: `upsert_rule_embedding` claims from under it. That is not handled here: `upsert_rule_embedding` claims
the rule's row before touching its vectors, and loses if it can't (#3262). the rule's row before touching its vectors, and loses if it can't.
""" """
try: try:
import asyncio import asyncio
@@ -394,11 +392,10 @@ def _refresh_rule_embedding(rule: Rule) -> None:
async def co_surfaced_partners(user_id: int, rule_ids: list[int]) -> list[Rule]: async def co_surfaced_partners(user_id: int, rule_ids: list[int]) -> list[Rule]:
"""Rules that must arrive WITH the given ones, because they fail together. """Rules that must arrive WITH the given ones, because they fail together.
This is the whole reason `co_surfaces` exists. Rule 144 was split off rule This is the whole reason `co_surfaces` exists. Two rules that guard one
46 and folded back into it the same day, on the correct observation that shape between them fail apart: either can surface without the other and
"either rule could surface without the other and miss exposing a project to leave a project seeing half of what the shape is meant to be. Without
what the entire shape is intended to be." Merging was the only fix this edge the only remedy is merging them into one rule.
available; this is the fix that should have been available.
Only rules the caller OWNS: an edge is not a back door into someone else's Only rules the caller OWNS: an edge is not a back door into someone else's
rulebook. Whether a partner can reach a given PROJECT is the caller's rulebook. Whether a partner can reach a given PROJECT is the caller's
@@ -441,7 +438,7 @@ async def rule_detail(
ONE seam for both doors and every write path, so create, update and get ONE seam for both doors and every write path, so create, update and get
cannot disagree about what a rule looks like coming back — the same cannot disagree about what a rule looks like coming back — the same
reasoning as attach_relations for notes (#2859), and the same reasoning reasoning as attach_relations for notes, and the same reasoning
rule_brief exists for one level down. rule_brief exists for one level down.
`system_ids=None` / `moments=None` mean "leave them alone"; a list `system_ids=None` / `moments=None` mean "leave them alone"; a list
@@ -457,7 +454,7 @@ async def rule_detail(
systems = (await list_rule_systems([rule.id])).get(rule.id, []) systems = (await list_rule_systems([rule.id])).get(rule.id, [])
mounted = (await list_rule_moments([rule.id])).get(rule.id, []) mounted = (await list_rule_moments([rule.id])).get(rule.id, [])
relations = (await list_rule_relations([rule.id])).get(rule.id, []) relations = (await list_rule_relations([rule.id])).get(rule.id, [])
# Attached only when present (#2483): an empty key reads as a capability # Attached only when present: an empty key reads as a capability
# the record has and isn't using, which is a different claim. # the record has and isn't using, which is a different claim.
if systems: if systems:
data["systems"] = systems data["systems"] = systems
@@ -466,7 +463,7 @@ async def rule_detail(
if relations: if relations:
data["relations"] = relations data["relations"] = relations
# The concrete situations judged (or proposed) to be instances of this # The concrete situations judged (or proposed) to be instances of this
# rule — milestone 440. Same present-only convention as the two above. # rule. Same present-only convention as the two above.
from scribe.services.lesson_rules import attach_rule_lessons from scribe.services.lesson_rules import attach_rule_lessons
await attach_rule_lessons(user_id, data, rule.id) await attach_rule_lessons(user_id, data, rule.id)
@@ -542,7 +539,7 @@ async def create_project_rule(
"""Create a rule scoped to a single project (no rulebook ceremony). """Create a rule scoped to a single project (no rulebook ceremony).
Project-scoped rules apply only to the named project: retrieval surfaces Project-scoped rules apply only to the named project: retrieval surfaces
them in that project's sessions and nowhere else (milestone 414), where a them in that project's sessions and nowhere else, where a
rule in a rulebook topic is global. Topic_id is left NULL — the CHECK rule in a rulebook topic is global. Topic_id is left NULL — the CHECK
constraint enforces exactly-one of (topic_id, project_id). constraint enforces exactly-one of (topic_id, project_id).
""" """
@@ -577,7 +574,7 @@ async def list_rules(
) -> list[Rule]: ) -> list[Rule]:
"""List rules by rulebook, topic or project. Ownership-scoped. """List rules by rulebook, topic or project. Ownership-scoped.
A rule has one home (milestone 414): a rulebook topic, where it is global, A rule has one home: a rulebook topic, where it is global,
or a project. So the filters name homes rather than reach: or a project. So the filters name homes rather than reach:
- `project_id` lists that project's OWN rules. Global rules apply to every - `project_id` lists that project's OWN rules. Global rules apply to every
@@ -588,9 +585,6 @@ async def list_rules(
- No filter lists every global rule. A user's project rules are left out: - No filter lists every global rule. A user's project rules are left out:
they belong to their projects, and mixing them into the rulebook listing they belong to their projects, and mixing them into the rulebook listing
would surprise its callers. would surprise its callers.
Before milestone 414, `project_id` returned the rules of every rulebook the
project SUBSCRIBED to plus its own. Subscriptions are gone.
""" """
from scribe.models.project import Project from scribe.models.project import Project
@@ -759,15 +753,15 @@ async def move_rule(
rule_id: int, user_id: int, *, topic_id: int = 0, project_id: int = 0, rule_id: int, user_id: int, *, topic_id: int = 0, project_id: int = 0,
) -> Optional[Rule]: ) -> Optional[Rule]:
"""Give a rule a new home — into a rulebook topic (global) or onto a """Give a rule a new home — into a rulebook topic (global) or onto a
project — keeping its id, history, Systems and relations (milestone 414). project — keeping its id, history, Systems and relations.
A rule's home IS its reach: in a topic it applies to every project, on a A rule's home IS its reach: in a topic it applies to every project, on a
project to that project alone. Recreating the rule in the other home and project to that project alone. Recreating the rule in the other home and
trashing the original would lose its id (and every record citing it), its trashing the original would lose its id (and every record citing it), its
edit history, its area tags and its typed edges, which is why this exists. edit history, its area tags and its typed edges, which is why this exists.
Exactly one of `topic_id` / `project_id`, matching the model's CHECK Exactly one of `topic_id` / `project_id`, matching the model's CHECK.
(migration 0059). Raises ValueError for: neither or both named, a target Raises ValueError for: neither or both named, a target
the caller does not own, the rule already living there, or a topic that the caller does not own, the rule already living there, or a topic that
already holds a live rule with this title (uq_rule_per_topic) — the message already holds a live rule with this title (uq_rule_per_topic) — the message
names that rule, rather than letting the constraint fail the commit. names that rule, rather than letting the constraint fail the commit.
@@ -775,10 +769,10 @@ async def move_rule(
WHAT A MOVE DOES NOT DO, deliberately: WHAT A MOVE DOES NOT DO, deliberately:
- No version. A rule's history records its TEXT (milestone 323, decision - No version. A rule's history records its TEXT; its place is not text,
4); its place is not text, and folding it in would make "version" mean and folding it in would make "version" mean two things. The rule's
two things. The rule's `updated_at` moves; say why a rule moved where `updated_at` moves; say why a rule moved where the decision is
the decision is recorded. recorded.
- No duplicate gate. Nothing new enters the corpus — the same rule changes - No duplicate gate. Nothing new enters the corpus — the same rule changes
home — so there is no second record to warn about. home — so there is no second record to warn about.
- No re-embed. The rule's document is its title, statement and trigger; - No re-embed. The rule's document is its title, statement and trigger;
@@ -820,7 +814,7 @@ async def move_rule(
return rule return rule
# ── Edit history (milestone 323) ─────────────────────────────────────── # ── Edit history ───────────────────────────────────────────────────────
# #
# The ACL-scoped reads live HERE rather than in services/rule_versions.py, # The ACL-scoped reads live HERE rather than in services/rule_versions.py,
# and not by preference: rulebooks imports rule_versions for the write path, # and not by preference: rulebooks imports rule_versions for the write path,
@@ -862,14 +856,14 @@ async def get_rule_version(rule_id: int, version_id: int, user_id: int):
)).scalar_one_or_none() )).scalar_one_or_none()
# ── Drift: what Scribe changed about how it works with you (#3895) ───── # ── Drift: what Scribe changed about how it works with you ─────────────
# #
# A preference is the one record kind the AGENT rewrites in the ordinary # A preference is the one record kind the AGENT rewrites in the ordinary
# course of working, which is the property that makes it useful and the # course of working, which is the property that makes it useful and the
# property that makes it dangerous. Milestone 399 named the risk up front: # property that makes it dangerous: an agent misreads one session, rewrites
# an agent misreads one session, rewrites a preference, and follows the # a preference, and follows the rewritten version forever while the operator
# rewritten version forever while the operator never sees the moment it # never sees the moment it changed — a confident wrong answer wearing the
# changed — a confident wrong answer wearing the operator's own authority. # operator's own authority.
# #
# `rule_versions` already records every rewrite. What it does not do is # `rule_versions` already records every rewrite. What it does not do is
# ARRIVE: a history you have to open one rule at a time, having first # ARRIVE: a history you have to open one rule at a time, having first
@@ -921,8 +915,8 @@ async def recent_preference_drift(user_id: int, limit: int = 20) -> list[dict]:
this get here" belongs. this get here" belongs.
Each row carries what it said before, what it says now, and `taught_by` — Each row carries what it said before, what it says now, and `taught_by` —
the record named by `arose_from_id`, which step 2 made required on every the record named by `arose_from_id`, which every agent-written
agent-written preference. That is the provenance: not who typed it (the preference must carry. That is the provenance: not who typed it (the
agent acts as the operator's own user, so the actor column cannot tell agent acts as the operator's own user, so the actor column cannot tell
them apart) but what the change was learned from. them apart) but what the change was learned from.
@@ -974,7 +968,7 @@ async def recent_preference_drift(user_id: int, limit: int = 20) -> list[dict]:
row: dict = { row: dict = {
# rule_brief, not a hand-written dict: the drift pane links # rule_brief, not a hand-written dict: the drift pane links
# straight into the editor, so the row has to be the same shape # straight into the editor, so the row has to be the same shape
# every other rule listing is (#3313's three-copies lesson). # every other rule listing is.
"rule": rule_brief(rule), "rule": rule_brief(rule),
# What it said BEFORE this rewrite. `statement` and # What it said BEFORE this rewrite. `statement` and
# `when_to_apply` only — those are the two fields that change how # `when_to_apply` only — those are the two fields that change how
@@ -1037,8 +1031,7 @@ async def restore_rule_version(
raise ValueError( raise ValueError(
"only a preference can be restored in one action. A rule is " "only a preference can be restored in one action. A rule is "
"the operator's own decision and reverting it goes through an " "the operator's own decision and reverting it goes through an "
"ordinary edit, so the reason for the rewrite stays visible " "ordinary edit, so the reason for the rewrite stays visible."
"(milestone 323)."
) )
version = (await session.execute( version = (await session.execute(
select(RuleVersion).where( select(RuleVersion).where(
@@ -1069,7 +1062,7 @@ async def restore_rule_version(
) )
# ── Canon tags + typed edges (milestone 307) ─────────────────────────── # ── Canon tags + typed edges ───────────────────────────────────────────
async def set_rule_systems( async def set_rule_systems(
rule_id: int, user_id: int, canonical_ids: list[int], rule_id: int, user_id: int, canonical_ids: list[int],
@@ -1114,7 +1107,7 @@ async def set_rule_moments(
rule_id: int, user_id: int, moments: list[str], *, rule_id: int, user_id: int, moments: list[str], *,
note: str = "", judged=(), verdict: str | None = None, note: str = "", judged=(), verdict: str | None = None,
) -> list[str] | None: ) -> list[str] | None:
"""Replace which MOMENTS a rule arrives at (milestone 458). None if not owned. """Replace which MOMENTS a rule arrives at. None if not owned.
Set-semantics like set_rule_systems: the list given IS the state after. Set-semantics like set_rule_systems: the list given IS the state after.
Every name is checked against the catalog first and the whole write is Every name is checked against the catalog first and the whole write is
@@ -1122,7 +1115,7 @@ async def set_rule_moments(
back as attached and never fire, the silent miss moments exist to end. back as attached and never fire, the silent miss moments exist to end.
THE ONE WRITE PATH for mounts, so it is also where a change is recorded as THE ONE WRITE PATH for mounts, so it is also where a change is recorded as
a judgment (step 7): what was added is confirmed, what was removed is a judgment: what was added is confirmed, what was removed is
rejected, in the same transaction as the mounts. `note`, `judged` and rejected, in the same transaction as the mounts. `note`, `judged` and
`verdict` come from `rule_moment_judgments.judge`, which says why. `verdict` come from `rule_moment_judgments.judge`, which says why.
""" """
@@ -1178,7 +1171,7 @@ async def rules_on_moments(
) -> list[tuple[Rule, str]]: ) -> list[tuple[Rule, str]]:
"""The rules mounted on any of these moments that this caller may be shown. """The rules mounted on any of these moments that this caller may be shown.
The delivery read (milestone 458 step 4), and a LOOKUP: no score, no bar. The delivery read, and a LOOKUP: no score, no bar.
Scoped by the same home clause the semantic search uses (rule_scope), so Scoped by the same home clause the semantic search uses (rule_scope), so
a mount never delivers another project's rule. Each rule appears once, a mount never delivers another project's rule. Each rule appears once,
under the first of `moments` it is mounted on — the caller passes them in under the first of `moments` it is mounted on — the caller passes them in
@@ -1389,17 +1382,15 @@ async def get_applicable_rules(
} }
NOT WHAT A SESSION RECEIVES. Rules reach a session by retrieval, which NOT WHAT A SESSION RECEIVES. Rules reach a session by retrieval, which
reads a rule's home (milestone 414): global rules everywhere, a project's reads a rule's home: global rules everywhere, a project's
own rules in that project. This is the listing a planning read carries so own rules in that project. This is the listing a planning read carries so
a reader can see which constraints are on the table, and it is narrower a reader can see which constraints are on the table, and it is narrower
than retrieval on purpose — "every global rule" is not a list anyone reads. than retrieval on purpose — "every global rule" is not a list anyone reads.
`rules` is the global rules TAGGED to a canonical area this project works `rules` is the global rules TAGGED to a canonical area this project works
in (milestone 307, D7): a deterministic tag match, never a similarity in: a deterministic tag match, never a similarity score. There is no
score. Before milestone 414 this was every rule in a SUBSCRIBED rulebook, opt-in to scope the untagged ones, and an untagged global rule is general
narrowed by area only where an author had tagged one. With subscriptions by construction — it arrives by retrieval when the
gone there is no opt-in left to scope the untagged ones, and an untagged
global rule is general by construction — it arrives by retrieval when the
work makes it relevant, like every other rule. work makes it relevant, like every other rule.
`project_rules` is the project's own, never filtered by area: a rule `project_rules` is the project's own, never filtered by area: a rule
@@ -1467,7 +1458,7 @@ async def get_applicable_rules(
)).all() )).all()
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows] project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
# Edges travel with the rules they belong to (milestone 307). # Edges travel with the rules they belong to.
# #
# A co_surfaces partner that was not otherwise selected is ADDED, because a # A co_surfaces partner that was not otherwise selected is ADDED, because a
# rule that arrives without the half it fails with is the failure the edge # rule that arrives without the half it fails with is the failure the edge
@@ -1510,9 +1501,8 @@ def rules_payload(
source. Every one of those surfaces is a bulk delivery — the applicable set source. Every one of those surfaces is a bulk delivery — the applicable set
handed over whole, chosen by nobody — so this is the one place that has to handed over whole, chosen by nobody — so this is the one place that has to
emit for all of them. Doing it per-caller instead would be five sites to emit for all of them. Doing it per-caller instead would be five sites to
remember, and #3430 gap 2 is what that costs: the process→skill sync went remember, and a site nobody is forced to account for goes un-emitted
un-emitted through an entire dedicated telemetry survey because nothing without anyone noticing.
forced its surface to be accounted for.
`source` stays the CALLER's name rather than a constant, so the readout can `source` stays the CALLER's name rather than a constant, so the readout can
still separate the session handshake from a mid-session milestone read; still separate the session handshake from a mid-session milestone read;
@@ -1527,12 +1517,11 @@ def rules_payload(
EVERY FORM LISTS, NONE RESTATES. Rules reach a session in full by EVERY FORM LISTS, NONE RESTATES. Rules reach a session in full by
retrieval, so these payloads say which constraints exist — id and title, retrieval, so these payloads say which constraints exist — id and title,
the topic a global rule sits in, `via` for a co_surfaces partner — and the topic a global rule sits in, `via` for a co_surfaces partner — and
get_rule reads one. Planning reads carried the full rule_brief until a get_rule reads one. A full rule_brief per rule would scale with every
project's listing grew to every global rule tagged to its areas global rule tagged to a project's areas, and a planning reply can run to
(milestone 414) and start_planning replied with 92k characters (#4081), tens of thousands of characters.
the shape #4045 had just removed from the handshake.
`brief` is the session handshake's form (#4045): the project's own rules `brief` is the session handshake's form: the project's own rules
only. Only what is shown is recorded as surfaced. only. Only what is shown is recorded as surfaced.
""" """
project_rules = [_rule_line(r) for r in applicable.get("project_rules", [])] project_rules = [_rule_line(r) for r in applicable.get("project_rules", [])]
@@ -1556,7 +1545,7 @@ def rules_payload(
def _rule_line(brief: dict) -> dict: def _rule_line(brief: dict) -> dict:
"""One rule as a listing names it: enough to recognise it and fetch it. """One rule as a listing names it: enough to recognise it and fetch it.
Keys a row does not carry are left out rather than sent empty (#2483).""" Keys a row does not carry are left out rather than sent empty."""
line = {"id": brief["id"], "title": brief["title"]} line = {"id": brief["id"], "title": brief["title"]}
for key in ("topic_title", "via"): for key in ("topic_title", "via"):
if brief.get(key): if brief.get(key):
@@ -1564,7 +1553,7 @@ def _rule_line(brief: dict) -> dict:
return line return line
# ── The staleness marker (milestone 323 step 5) ──────────────────────── # ── The staleness marker ───────────────────────────────────────────────
# #
# WHAT THIS CAN AND CANNOT SEE. An etag catches a rule that MOVED after a # WHAT THIS CAN AND CANNOT SEE. An etag catches a rule that MOVED after a
# session loaded it. It is not a general staleness check, and a reader who # session loaded it. It is not a general staleness check, and a reader who
@@ -1585,7 +1574,7 @@ def _rule_line(brief: dict) -> dict:
_ETAG_EMPTY = "empty|0" _ETAG_EMPTY = "empty|0"
# ── The staleness sweep (milestone 312) ──────────────────────────────── # ── The staleness sweep ────────────────────────────────────────────────
async def rules_due_for_verification( async def rules_due_for_verification(
user_id: int, user_id: int,