feat(rules)!: retire rulebook subscriptions and per-project suppressions (#4052)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped

A rule's home is its scope now: a rule in a rulebook topic is global, a rule on
a project applies to that project, and retrieval reads that directly (#4074).
A subscription had stopped changing anything a session received; a suppression
muted rules from a subscription. Operator, 2026-09-15: "we have global and
project scoped rules, we don't need the subscriptions now."

What goes, whole (rule 22):
- Migration 0101 drops project_rulebook_subscriptions, project_rule_suppressions
  and project_topic_suppressions, and strips subscribe_rulebooks (and 394's
  leftover exclude_always_on_rulebooks) from stored inception choices.
- Service, MCP and REST: subscribe/unsubscribe and the four suppress/unsuppress
  operations. The Subscribers checklist, the subscribe chips, the skip buttons
  and the Suppressed section in the rules UI.
- Inception asks two questions (design system, seed Systems). create_project and
  decide_project_inception lose subscribe_rulebooks.
- Backup v15 stops exporting the three sections; older archives still restore,
  the keys simply unread. Trash no longer hard-deletes suppression rows.

What changes meaning:
- get_applicable_rules is a project's LISTING: its own rules, plus the global
  rules tagged to an area it works in. Untagged global rules apply everywhere
  and arrive by retrieval, so they are not listed. A co_surfaces partner on a
  different project is not dragged in.
- list_rules(project_id) lists that project's own rules.
- rules_payload drops subscribed_rulebooks and suppressed_*; the handshake's
  brief form is project_rules alone.
- using-scribe's "Where a new rule goes" and inception sections, tool
  docstrings and docs say global vs project. Plugin 2026.09.15.1620.

Milestone 414 step 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-15 12:20:57 -04:00
co-authored by Claude Opus 5
parent 188e78bbcd
commit 0bcd4b5540
43 changed files with 579 additions and 1610 deletions
+11 -89
View File
@@ -22,9 +22,6 @@ from scribe.models.rulebook import (
Rule,
Rulebook,
RulebookTopic,
project_rule_suppressions,
project_rulebook_subscriptions,
project_topic_suppressions,
)
from scribe.models.setting import Setting
from scribe.models.system import RecordSystem, System
@@ -66,8 +63,12 @@ logger = logging.getLogger(__name__)
# (milestone 333). Carrying it is the WHOLE REASON the table is separate: the
# note importer maps note_id through note_id_map, so a rule id parked there
# would restore attached to whatever note took that number.
# v15 (2026-09) dropped rulebook_subscriptions / rule_suppressions /
# topic_suppressions with their tables (milestone 414): a rule's scope is its
# home now. Older archives carrying those sections still restore — the keys are
# simply not read — as do the subscribe_rulebooks inception choices they hold.
# Bump when the serialized schema changes.
BACKUP_VERSION = 14
BACKUP_VERSION = 15
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -80,8 +81,6 @@ BACKUP_VERSION = 14
_BACKED_UP = [
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
# v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings", "note_supersessions",
@@ -234,18 +233,6 @@ def _d(val: str | None) -> date | None:
return date.fromisoformat(val) if val else None
def _subscription_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows]
def _rule_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "rule_id": r.rule_id} for r in rows]
def _topic_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
# The v5 sections. Pure row-builders like the join-table helpers above, for the
@@ -640,15 +627,6 @@ async def export_full_backup() -> dict:
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all()
subscriptions = (await session.execute(
select(project_rulebook_subscriptions)
)).all()
rule_suppressions = (await session.execute(
select(project_rule_suppressions)
)).all()
topic_suppressions = (await session.execute(
select(project_topic_suppressions)
)).all()
return {
"version": BACKUP_VERSION,
@@ -671,9 +649,6 @@ async def export_full_backup() -> dict:
"rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics),
"rules": _rule_rows(rules),
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations),
@@ -825,24 +800,6 @@ async def export_user_backup(user_id: int) -> dict:
RuleRelation.to_rule_id.in_(_rule_ids),
)
)).scalars().all() if _rule_ids else []
if project_ids:
subscriptions = (await session.execute(
select(project_rulebook_subscriptions).where(
project_rulebook_subscriptions.c.project_id.in_(project_ids)
)
)).all()
rule_suppressions = (await session.execute(
select(project_rule_suppressions).where(
project_rule_suppressions.c.project_id.in_(project_ids)
)
)).all()
topic_suppressions = (await session.execute(
select(project_topic_suppressions).where(
project_topic_suppressions.c.project_id.in_(project_ids)
)
)).all()
else:
subscriptions = rule_suppressions = topic_suppressions = []
return {
"version": BACKUP_VERSION,
@@ -867,9 +824,6 @@ async def export_user_backup(user_id: int) -> dict:
"rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics),
"rules": _rule_rows(rules),
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations),
@@ -1014,8 +968,6 @@ async def _restore_v2(data: dict) -> dict:
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
"rulebook_subscriptions": 0, "rule_suppressions": 0,
"topic_suppressions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
"repo_bindings": 0,
@@ -1300,38 +1252,10 @@ async def _restore_v2(data: dict) -> dict:
rule_id_map[r_data["id"]] = rule.id
stats["rules"] += 1
# 12. Rulebook subscriptions (v3 join table)
for sub in data.get("rulebook_subscriptions", []):
mapped_pid = project_id_map.get(sub.get("project_id", 0))
mapped_rbid = rulebook_id_map.get(sub.get("rulebook_id", 0))
if mapped_pid is None or mapped_rbid is None:
continue
await session.execute(project_rulebook_subscriptions.insert().values(
project_id=mapped_pid, rulebook_id=mapped_rbid,
))
stats["rulebook_subscriptions"] += 1
# 13. Rule suppressions (v3 join table)
for sup in data.get("rule_suppressions", []):
mapped_pid = project_id_map.get(sup.get("project_id", 0))
mapped_rid = rule_id_map.get(sup.get("rule_id", 0))
if mapped_pid is None or mapped_rid is None:
continue
await session.execute(project_rule_suppressions.insert().values(
project_id=mapped_pid, rule_id=mapped_rid,
))
stats["rule_suppressions"] += 1
# 14. Topic suppressions (v3 join table)
for sup in data.get("topic_suppressions", []):
mapped_pid = project_id_map.get(sup.get("project_id", 0))
mapped_tid = topic_id_map.get(sup.get("topic_id", 0))
if mapped_pid is None or mapped_tid is None:
continue
await session.execute(project_topic_suppressions.insert().values(
project_id=mapped_pid, topic_id=mapped_tid,
))
stats["topic_suppressions"] += 1
# 12-14. Rulebook subscriptions, rule and topic suppressions (v3-v14)
# `rulebook_subscriptions`, `rule_suppressions` and `topic_suppressions`
# are READ BY NOBODY since milestone 414 dropped their tables. An
# archive carrying them still imports, for the reason 14b gives.
# 14b. Always-on rulebook exclusions (v10, milestone 297)
# `rulebook_exclusions` was a v10 section and is READ BY NOBODY since
@@ -1669,10 +1593,8 @@ async def _restore_v2(data: dict) -> dict:
# so the next edit to that project would fail on data this
# importer wrote.
choices.pop("exclude_always_on_rulebooks", None)
choices["subscribe_rulebooks"] = [
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
if i in rulebook_id_map
]
# Same for subscribe_rulebooks since milestone 414.
choices.pop("subscribe_rulebooks", None)
ds = choices.get("design_system_id")
choices["design_system_id"] = design_system_id_map.get(ds) if ds else None
proj.inception = {**inception, "choices": choices}