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:
@@ -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
|
||||
# a caller can be corrected before the database refuses it (rule 36 keeps the
|
||||
# two in step; this keeps the error readable).
|
||||
# a caller can be corrected before the database refuses it (a migration that
|
||||
# widens one widens the other; this keeps the error readable).
|
||||
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||
# 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.
|
||||
@@ -303,23 +303,21 @@ def _valid_kind(kind: str) -> str:
|
||||
return kind if kind in KINDS else "rule"
|
||||
|
||||
|
||||
# Re-exported, not redefined. Notes gained the same trio in milestone 317 and
|
||||
# this reading of it is genuinely common, so it moved to services/verification
|
||||
# — the DRY win note 3163 names, as against sharing the QUERY, which the two
|
||||
# record types cannot (a rule scopes by rulebook ownership, a note by the note
|
||||
# ACL). Kept importable from here because callers already reach for it here.
|
||||
# Re-exported, not redefined. Notes carry the same verification trio and this
|
||||
# reading of it is common to both, so it lives in services/verification —
|
||||
# sharing the reading, not the QUERY, which the two record types cannot share
|
||||
# (a rule scopes by rulebook ownership, a note by the note ACL). Importable
|
||||
# from here because callers reach for it here.
|
||||
last_verified_label = _last_verified_label
|
||||
|
||||
|
||||
def rule_brief(rule: Rule, **extra) -> dict:
|
||||
"""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
|
||||
were three copies of this dict and they had already diverged — two carried
|
||||
`topic_id`, one didn't, and none carried the timestamps the model has held
|
||||
all along. That omission is why a rule written before the capability it
|
||||
duplicates was indistinguishable, at read time, from one still doing work
|
||||
(the FabledCurator case, note 3026).
|
||||
One builder for every payload that hands rules to an agent, because
|
||||
copies of this dict diverge. It carries the timestamps: without them a
|
||||
rule written before the capability it duplicates is indistinguishable, at
|
||||
read time, from one still doing work.
|
||||
|
||||
`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
|
||||
@@ -343,8 +341,8 @@ def rule_brief(rule: Rule, **extra) -> dict:
|
||||
"kind": rule.kind or "rule",
|
||||
"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
|
||||
# capability the record doesn't have).
|
||||
# Attached only when present: never a null key that reads as a
|
||||
# capability the record doesn't have.
|
||||
if rule.when_to_apply:
|
||||
out["when_to_apply"] = rule.when_to_apply
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
# nobody has ever verified.
|
||||
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
|
||||
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:
|
||||
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]:
|
||||
"""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
|
||||
46 and folded back into it the same day, on the correct observation that
|
||||
"either rule could surface without the other and miss exposing a project to
|
||||
what the entire shape is intended to be." Merging was the only fix
|
||||
available; this is the fix that should have been available.
|
||||
This is the whole reason `co_surfaces` exists. Two rules that guard one
|
||||
shape between them fail apart: either can surface without the other and
|
||||
leave a project seeing half of what the shape is meant to be. Without
|
||||
this edge the only remedy is merging them into one rule.
|
||||
|
||||
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
|
||||
@@ -441,7 +438,7 @@ async def rule_detail(
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
`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, [])
|
||||
mounted = (await list_rule_moments([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.
|
||||
if systems:
|
||||
data["systems"] = systems
|
||||
@@ -466,7 +463,7 @@ async def rule_detail(
|
||||
if relations:
|
||||
data["relations"] = relations
|
||||
# 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
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
constraint enforces exactly-one of (topic_id, project_id).
|
||||
"""
|
||||
@@ -577,7 +574,7 @@ async def list_rules(
|
||||
) -> list[Rule]:
|
||||
"""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:
|
||||
|
||||
- `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:
|
||||
they belong to their projects, and mixing them into the rulebook listing
|
||||
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
|
||||
|
||||
@@ -759,15 +753,15 @@ async def move_rule(
|
||||
rule_id: int, user_id: int, *, topic_id: int = 0, project_id: int = 0,
|
||||
) -> Optional[Rule]:
|
||||
"""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
|
||||
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
|
||||
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
|
||||
(migration 0059). Raises ValueError for: neither or both named, a target
|
||||
Exactly one of `topic_id` / `project_id`, matching the model's CHECK.
|
||||
Raises ValueError for: neither or both named, a target
|
||||
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
|
||||
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:
|
||||
|
||||
- No version. A rule's history records its TEXT (milestone 323, decision
|
||||
4); its place is not text, and folding it in would make "version" mean
|
||||
two things. The rule's `updated_at` moves; say why a rule moved where
|
||||
the decision is recorded.
|
||||
- No version. A rule's history records its TEXT; its place is not text,
|
||||
and folding it in would make "version" mean two things. The rule's
|
||||
`updated_at` moves; say why a rule moved where the decision is
|
||||
recorded.
|
||||
- No duplicate gate. Nothing new enters the corpus — the same rule changes
|
||||
home — so there is no second record to warn about.
|
||||
- No re-embed. The rule's document is its title, statement and trigger;
|
||||
@@ -820,7 +814,7 @@ async def move_rule(
|
||||
return rule
|
||||
|
||||
|
||||
# ── Edit history (milestone 323) ───────────────────────────────────────
|
||||
# ── Edit history ───────────────────────────────────────────────────────
|
||||
#
|
||||
# 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,
|
||||
@@ -862,14 +856,14 @@ async def get_rule_version(rule_id: int, version_id: int, user_id: int):
|
||||
)).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
|
||||
# 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:
|
||||
# an agent misreads one session, rewrites a preference, and follows the
|
||||
# rewritten version forever while the operator never sees the moment it
|
||||
# changed — a confident wrong answer wearing the operator's own authority.
|
||||
# property that makes it dangerous: an agent misreads one session, rewrites
|
||||
# a preference, and follows the rewritten version forever while the operator
|
||||
# never sees the moment it changed — a confident wrong answer wearing the
|
||||
# operator's own authority.
|
||||
#
|
||||
# `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
|
||||
@@ -921,8 +915,8 @@ async def recent_preference_drift(user_id: int, limit: int = 20) -> list[dict]:
|
||||
this get here" belongs.
|
||||
|
||||
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
|
||||
agent-written preference. That is the provenance: not who typed it (the
|
||||
the record named by `arose_from_id`, which every agent-written
|
||||
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
|
||||
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 = {
|
||||
# rule_brief, not a hand-written dict: the drift pane links
|
||||
# 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),
|
||||
# What it said BEFORE this rewrite. `statement` and
|
||||
# `when_to_apply` only — those are the two fields that change how
|
||||
@@ -1037,8 +1031,7 @@ async def restore_rule_version(
|
||||
raise ValueError(
|
||||
"only a preference can be restored in one action. A rule is "
|
||||
"the operator's own decision and reverting it goes through an "
|
||||
"ordinary edit, so the reason for the rewrite stays visible "
|
||||
"(milestone 323)."
|
||||
"ordinary edit, so the reason for the rewrite stays visible."
|
||||
)
|
||||
version = (await session.execute(
|
||||
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(
|
||||
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], *,
|
||||
note: str = "", judged=(), verdict: str | None = 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.
|
||||
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.
|
||||
|
||||
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
|
||||
`verdict` come from `rule_moment_judgments.judge`, which says why.
|
||||
"""
|
||||
@@ -1178,7 +1171,7 @@ async def rules_on_moments(
|
||||
) -> list[tuple[Rule, str]]:
|
||||
"""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
|
||||
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
|
||||
@@ -1389,17 +1382,15 @@ async def get_applicable_rules(
|
||||
}
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
`rules` is the global rules TAGGED to a canonical area this project works
|
||||
in (milestone 307, D7): a deterministic tag match, never a similarity
|
||||
score. Before milestone 414 this was every rule in a SUBSCRIBED rulebook,
|
||||
narrowed by area only where an author had tagged one. With subscriptions
|
||||
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
|
||||
in: a deterministic tag match, never a similarity score. There is no
|
||||
opt-in 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.
|
||||
|
||||
`project_rules` is the project's own, never filtered by area: a rule
|
||||
@@ -1467,7 +1458,7 @@ async def get_applicable_rules(
|
||||
)).all()
|
||||
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
|
||||
# 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
|
||||
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
|
||||
remember, and #3430 gap 2 is what that costs: the process→skill sync went
|
||||
un-emitted through an entire dedicated telemetry survey because nothing
|
||||
forced its surface to be accounted for.
|
||||
remember, and a site nobody is forced to account for goes un-emitted
|
||||
without anyone noticing.
|
||||
|
||||
`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;
|
||||
@@ -1527,12 +1517,11 @@ def rules_payload(
|
||||
EVERY FORM LISTS, NONE RESTATES. Rules reach a session in full by
|
||||
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
|
||||
get_rule reads one. Planning reads carried the full rule_brief until a
|
||||
project's listing grew to every global rule tagged to its areas
|
||||
(milestone 414) and start_planning replied with 92k characters (#4081),
|
||||
the shape #4045 had just removed from the handshake.
|
||||
get_rule reads one. A full rule_brief per rule would scale with every
|
||||
global rule tagged to a project's areas, and a planning reply can run to
|
||||
tens of thousands of characters.
|
||||
|
||||
`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.
|
||||
"""
|
||||
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:
|
||||
"""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"]}
|
||||
for key in ("topic_title", "via"):
|
||||
if brief.get(key):
|
||||
@@ -1564,7 +1553,7 @@ def _rule_line(brief: dict) -> dict:
|
||||
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
|
||||
# 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"
|
||||
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
# ── The staleness sweep ────────────────────────────────────────────────
|
||||
|
||||
async def rules_due_for_verification(
|
||||
user_id: int,
|
||||
|
||||
Reference in New Issue
Block a user