diff --git a/src/scribe/services/rulebooks.py b/src/scribe/services/rulebooks.py index 7c33b0ca..7ba43f02 100644 --- a/src/scribe/services/rulebooks.py +++ b/src/scribe/services/rulebooks.py @@ -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,