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
+1 -1
View File
@@ -43,7 +43,7 @@ async def get_milestone(milestone_id: int) -> dict:
rules surface again on recall.
Returns: milestone (incl. body), progress, steps (its tasks ordered by
status then update), and applicable_rules / subscribed_rulebooks.
status then update), and applicable_rules / project_rules.
"""
uid = current_user_id()
milestone = await milestones_svc.get_milestone(uid, milestone_id)
+2 -2
View File
@@ -384,8 +384,8 @@ async def notes_due_for_verification(
thing there is. 0 = no age filter.
project_id: narrow to one project. 0 = every project. Unlike the rules
sweep, this filter is safe: a note belongs to at most one project
outright, with none of the subscription paths that would make a
project filter UNDER-report a rule.
outright, where a project is bound by every GLOBAL rule as well as
its own — so a project filter would UNDER-report rules.
never_only: only notes nobody has ever verified.
"""
uid = current_user_id()
+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}
+30 -121
View File
@@ -1,6 +1,6 @@
"""MCP tools for the Scribe Rulebook system.
Rulebook / topic / rule CRUD, subscription management, and the rule-to-rule
Rulebook / topic / rule CRUD and the rule-to-rule
edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
service, and the record shape comes from rule_brief / rule_detail there rather
than being rebuilt here.
@@ -46,16 +46,14 @@ async def get_rulebook(rulebook_id: int) -> dict:
async def create_rulebook(title: str, description: str = "") -> dict:
"""Create a new rulebook (a shared, reusable module of general rules).
"""Create a new rulebook (a themed grouping of GLOBAL rules).
A rulebook reaches a project ONE way: the project subscribes to it
(subscribe_project_to_rulebook). There was a second until milestone 394 —
an `always_on` flag that bound every project automatically — and it is
gone with the tier it belonged to. Opt-in is now the whole model, so a
rulebook binds what asked for it and nothing else.
A rulebook is SHARED, so its rules must stay general — agnostic
to any single project. Project-specific rules go in create_project_rule.
A rule in a rulebook is global: it applies in every project its owner works
on, and reaches a session by retrieval when the work makes it relevant
(milestone 414). There is no subscribing a project to a rulebook, and no
muting one per project — that machinery is gone. So a rulebook's rules must
stay general, agnostic to any single project. Project-specific rules go in
create_project_rule.
Args:
title: Rulebook name.
@@ -210,10 +208,12 @@ async def list_rules(
Args:
rulebook_id: 0 = no filter; positive = restrict to that rulebook.
topic_id: 0 = no filter; positive = restrict to that topic.
project_id: 0 = no filter; positive = restrict to rules applicable
to that project (via its rulebook subscriptions).
project_id: 0 = no filter; positive = that project's OWN rules.
Global rules apply to every project, so they are listed by
rulebook or topic (or unfiltered), not under each project.
All filters are AND-combined; ownership-scoped.
rulebook_id and topic_id AND-combine; project_id lists a project's rules
on its own. Ownership-scoped.
"""
uid = current_user_id()
rows = await rulebooks_svc.list_rules(
@@ -308,12 +308,12 @@ async def create_rule(
and let the answer stand; re-raising a declined proposal argues a rule
into existence, which is the thing this whole loop exists to prevent.
A rulebook rule is shared by every project subscribed to the rulebook, so
it must read as a general standard
never pin it to one project's files, paths, or quirks. For a rule that
applies to a single project only, use create_project_rule instead (no
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares,
put it in a rulebook for that category and subscribe those projects to it.
A rulebook rule is GLOBAL — it applies in every project — so it must read as
a general standard: never pin it to one project's files, paths, or quirks.
For a rule that applies to a single project only, use create_project_rule
instead (no rulebook+topic ceremony). A standard only some projects share
is still global in reach; write it so it names the kind of work it is
about, and it arrives where that work happens.
Write it general WITHOUT hedging for the exceptions. A project that needs
to strengthen, narrow or replace this rule writes its own and links it
@@ -334,7 +334,7 @@ async def create_rule(
own, and fixing that breakage doesn't require the neighbouring clauses, it
is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules
(kind="co_surfaces"), never merged into one row: a merged rule cannot be
cited, surfaced or suppressed a clause at a time, and it grows without
cited or surfaced a clause at a time, and it grows without
limit because adding to it is always cheaper than adding a rule.
Args:
@@ -420,11 +420,11 @@ async def create_project_rule(
Use this for anything SPECIFIC to one project — its files, paths, layout,
or quirks. This is the correct home for the project-specific detail that
must NOT go into a shared rulebook (where it would leak to every other
project that gets the rulebook). General standards belong in a rulebook
instead (create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony;
the rule is returned in get_project's applicable_rules (under
project_rules) and in list_rules(project_id=...).
must NOT go into a rulebook (where it would be global, and reach every
other project). General standards belong in a rulebook instead
(create_rule). It bypasses the Rulebook -> Topic -> Rule ceremony; the
rule surfaces by retrieval in this project's sessions only, and is listed
in get_project's project_rules and in list_rules(project_id=...).
PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole
loop: the four things a proposal states (what it would require, its
@@ -882,94 +882,6 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
f"Restore with restore('{batch}')."}
# ── Subscriptions ──────────────────────────────────────────────────────
async def subscribe_project_to_rulebook(
project_id: int, rulebook_id: int,
) -> dict:
"""Subscribe a project to a rulebook — its rules then bind that project.
Subscription is the ONLY path for a rulebook (milestone 394): a reusable,
themed module of GENERAL rules shared across the projects that subscribe.
Subscribe a project because it fits the rulebook's theme (e.g. a visual app
-> the design-system rulebook), not to host rules about this one project —
those belong in create_project_rule.
"""
uid = current_user_id()
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": True}
async def unsubscribe_project_from_rulebook(
project_id: int, rulebook_id: int,
) -> dict:
"""Remove a project's subscription to a rulebook."""
uid = current_user_id()
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def suppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Mute a single rulebook rule for one project.
The rule stays in its rulebook for other projects; only this project
skips it. Idempotent. Use unsuppress_rule_for_project to re-enable.
Project-scoped rules (create_project_rule) are NOT suppressible — delete
them with delete_rule instead.
"""
uid = current_user_id()
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": True}
async def unsuppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
"""Re-enable a previously-suppressed rule for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=uid,
)
return {"project_id": project_id, "rule_id": rule_id, "suppressed": False}
async def suppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Mute every rule under a topic for one project.
Equivalent to suppressing each rule in the topic individually, but
auto-includes new rules added to the topic later. Idempotent.
"""
uid = current_user_id()
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": True}
async def unsuppress_topic_for_project(
project_id: int, topic_id: int,
) -> dict:
"""Re-enable a previously-suppressed topic for one project. Idempotent."""
uid = current_user_id()
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=uid,
)
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
async def relate_rules(
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
) -> dict:
@@ -981,7 +893,7 @@ async def relate_rules(
together. Use it when you are tempted to fold one rule into another
because "either could surface without the other": that instinct is
right and merging is the wrong fix, because a merged rule cannot be
cited, suppressed or surfaced a clause at a time. Symmetric — draw it
cited or surfaced a clause at a time. Symmetric — draw it
once, it reads from both ends.
- kind="overrides" — this rule supersedes that one for its scope. Use it
when a project rule is stricter than, or replaces, an inherited one,
@@ -1046,10 +958,10 @@ async def rules_due_for_verification(
Never-checked rules always qualify. 0 = no age filter.
never_only: only rules nobody has ever verified.
NOT filterable by project, deliberately: a project reaches rules through
project scope and rulebook subscriptions, and a filter that missed one of
those paths would UNDER-report — which is the
exact failure this whole surface exists to prevent. Read the whole list.
NOT filterable by project, deliberately: a project is bound by its own
rules AND every global rule, and a filter that dropped the global ones
would UNDER-report — which is the exact failure this whole surface exists
to prevent. Read the whole list.
"""
uid = current_user_id()
rules = await rulebooks_svc.rules_due_for_verification(
@@ -1110,9 +1022,6 @@ def register(mcp) -> None:
create_rule, create_project_rule, update_rule, delete_rule,
create_preference, update_preference,
relate_rules, unrelate_rules,
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
rules_due_for_verification, mark_rule_verified,
rule_history,
):
+2 -3
View File
@@ -81,9 +81,8 @@ async def get_task(task_id: int) -> dict:
(the areas this task is filed under; read a subsystem's whole pile with
list_system_records) or, for an untagged project task, the `systems_hint`
question. For legacy
kind=plan tasks, the response also includes applicable_rules +
subscribed_rulebooks from the task's project's rulebook subscriptions (new
plans are milestones — use get_milestone for those).
kind=plan tasks, the response also includes the project's applicable_rules
and project_rules (new plans are milestones — use get_milestone for those).
A task another user shared with you also carries `shared`, `owner` and
`permission` — it's their work item, not one you took on.