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
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user