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
+26 -41
View File
@@ -66,7 +66,7 @@ async def enter_project(project_id: int) -> dict:
project_id: The project to enter.
Returns a dict with keys: project, milestone_summary, open_tasks, systems,
design_system, project_rules, subscribed_rulebooks, pattern_coverage —
design_system, project_rules, pattern_coverage —
plus milestone_summary_omitted, inception and systems_bootstrap, each
present only when it applies (see below).
@@ -83,11 +83,12 @@ async def enter_project(project_id: int) -> dict:
with or without a milestone. A work-log counts as touching its task. Each
names its milestone. list_tasks has the rest.
`project_rules` lists the project's own rules by id and title, and
`subscribed_rulebooks` the rulebooks it draws on. A rule reaches you in
full when your work matches it; get_rule(id) reads one, and
search(content_type="rule") asks whether one covers what you are about
to do.
`project_rules` lists the project's own rules by id and title. Global
rules (the ones in rulebooks) apply here too and are not listed. Any rule
reaches you in full when your work matches it — a global one or one of
this project's, never another project's; get_rule(id) reads one, and
search(content_type="rule", project_id=...) asks whether one covers what
you are about to do.
`pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon
@@ -109,7 +110,7 @@ async def enter_project(project_id: int) -> dict:
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(the rulebooks it could subscribe to, design system, Systems), what to ask the
(design system, Systems), what to ask the
operator — once — and the decide_project_inception call that answers it;
it repeats on every enter until a decision is recorded.
@@ -178,8 +179,7 @@ async def enter_project(project_id: int) -> dict:
)
# The inception ask (milestone 297): a project nobody has decided on
# inherits nothing, silently — no rulebook subscriptions, no design system,
# no Systems. Owner-only (deciding is the owner's), and only until a
# inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a
# decision is recorded; the key is ABSENT otherwise (#2483).
inception_ask = None
if project.user_id == uid and not inception_svc.is_decided(project):
@@ -268,8 +268,9 @@ async def get_project(project_id: int) -> dict:
Returns full project fields, a milestone_summary list (every milestone,
with description and progress but no plan body; get_milestone reads a
plan), and the rulebook-applicable_rules / subscribed_rulebooks pair the
assistant should consult when working on this project.
plan), the project's own rules (project_rules), and applicable_rules: the
global rules tagged to an area this project works in. Every other global
rule applies too and arrives by retrieval when the work matches it.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
@@ -285,18 +286,14 @@ async def get_project(project_id: int) -> dict:
return data
def _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
) -> dict | None:
def _inception_choices(design_system_id, seed_systems) -> dict | None:
"""The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system."""
if (subscribe_rulebooks is None
and not design_system_id and seed_systems is None):
if not design_system_id and seed_systems is None:
return None
return {
"subscribe_rulebooks": list(subscribe_rulebooks or []),
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
}
@@ -308,18 +305,18 @@ async def create_project(
goal: str = "",
status: str = "active",
color: str = "",
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297):
before calling, ask the operator the four inception questions and pass
the answers; a project created without any of them is UNDECIDED and
before calling, ask the operator the two inception questions and pass
the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: no rulebook subscriptions, no design system,
no Systems.
Defaults if nobody decides: no design system, no Systems. Rules are not
an inception question: global rules (in rulebooks) apply to every
project, and a project's own rules are written with create_project_rule.
Args:
title: Project name (required).
@@ -327,10 +324,6 @@ async def create_project(
goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
subscribe_rulebooks: rulebook ids this project opts into.
Subscription is the only way a rulebook binds a project, so a
rulebook left out simply does not apply. list_rulebooks shows
which exist.
design_system_id: the design system this project's UI is built from
(list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release,
@@ -346,9 +339,7 @@ async def create_project(
color=color or None,
)
data = project.to_dict()
choices = _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
)
choices = _inception_choices(design_system_id, seed_systems)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"]
@@ -364,7 +355,6 @@ async def create_project(
async def decide_project_inception(
project_id: int,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
) -> dict:
@@ -372,22 +362,17 @@ async def decide_project_inception(
or re-decide later (milestone 297).
Owner-only. Applies the effects through the ordinary tools' paths —
subscribe_project_to_rulebook,
set_project_design_system, the standard Systems seed — and writes the
set_project_design_system and the standard Systems seed — and writes the
decision on the project last, so get_project/enter_project can say why
the project has the rules, design and Systems it has. Re-deciding is
additive for subscriptions (use
unsubscribe_project_from_rulebook to undo one), replaces the design
system, and never re-seeds Systems a project already has.
the project has the design and Systems it has. Re-deciding replaces the
design system and never re-seeds Systems a project already has.
Args: as create_project's inception args. Passing nothing records a
decision to take nothing (no subscriptions, no design system, no seed) —
a valid answer, stated.
decision to take nothing (no design system, no seed) — a valid answer,
stated.
"""
uid = current_user_id()
choices = _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
) or {}
choices = _inception_choices(design_system_id, seed_systems) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}