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:
@@ -172,23 +172,7 @@ async def test_get_rule_returns_none_when_not_owner():
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── Subscriptions + applicable_rules ────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_project_requires_owned_rulebook():
|
||||
"""subscribe_project raises if user doesn't own the rulebook."""
|
||||
mock_session = make_mock_session()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import subscribe_project
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await subscribe_project(
|
||||
project_id=1, rulebook_id=999, user_id=7,
|
||||
)
|
||||
|
||||
# ── applicable_rules ────────────────────────────────────────────────────
|
||||
|
||||
def _empty():
|
||||
"""A MagicMock result whose .all() returns [] (or .scalars().all() returns [])."""
|
||||
@@ -217,13 +201,18 @@ def _no_edges():
|
||||
)
|
||||
|
||||
|
||||
def _areas(*canonical_ids):
|
||||
"""The project-areas query's result: `.scalars().all()` of canonical ids."""
|
||||
r = MagicMock()
|
||||
r.scalars.return_value.all.return_value = list(canonical_ids)
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_returns_shape():
|
||||
"""get_applicable_rules returns the full projection — including the
|
||||
new suppression fields and rulebook/topic IDs on each rule."""
|
||||
"""The listing is a project's own rules plus the global rules tagged to its
|
||||
areas (milestone 414) — no subscriptions or suppressions in the shape."""
|
||||
mock_session = make_mock_session()
|
||||
sub_result = MagicMock()
|
||||
sub_result.all.return_value = [(1, "FabledSword family")]
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = [
|
||||
# The query selects the ENTITY plus three labels, so rule_brief stays
|
||||
@@ -232,10 +221,9 @@ async def test_get_applicable_rules_returns_shape():
|
||||
"git-workflow", 1, "FabledSword family")
|
||||
for i in range(50)
|
||||
]
|
||||
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q,
|
||||
# project-areas_q (milestone 307), rules_q, proj_rules_q
|
||||
# Execute order: project-areas_q, rules_q (only with areas), proj_rules_q
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
|
||||
_areas(4), rules_result, _empty(),
|
||||
])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
@@ -244,19 +232,11 @@ async def test_get_applicable_rules_returns_shape():
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
|
||||
|
||||
assert "rules" in result
|
||||
assert "project_rules" in result
|
||||
assert "suppressed_rules" in result
|
||||
assert "suppressed_topics" in result
|
||||
assert "truncated" in result
|
||||
assert "subscribed_rulebooks" in result
|
||||
assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
|
||||
assert set(result) == {"rules", "project_rules", "truncated"}
|
||||
assert len(result["rules"]) == 50
|
||||
assert result["rules"][0]["topic_id"] == 2
|
||||
assert result["rules"][0]["rulebook_id"] == 1
|
||||
assert result["project_rules"] == []
|
||||
assert result["suppressed_rules"] == []
|
||||
assert result["suppressed_topics"] == []
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
@@ -264,14 +244,12 @@ async def test_get_applicable_rules_returns_shape():
|
||||
async def test_get_applicable_rules_truncates_when_over_limit():
|
||||
"""When limit+1 rows are returned, truncated=True and only `limit` returned."""
|
||||
mock_session = make_mock_session()
|
||||
sub_result = MagicMock()
|
||||
sub_result.all.return_value = []
|
||||
rules_result = MagicMock()
|
||||
rules_result.all.return_value = [
|
||||
(fake_rule(id=i, title=f"r{i}"), "topic", 1, "rb") for i in range(51)
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
|
||||
_areas(4), rules_result, _empty(),
|
||||
])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
@@ -284,6 +262,24 @@ async def test_get_applicable_rules_truncates_when_over_limit():
|
||||
assert len(result["rules"]) == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_project_with_no_areas_lists_no_global_rules():
|
||||
"""Untagged global rules apply everywhere and arrive by retrieval; the
|
||||
listing only names the ones bound by area, so no areas means none — and
|
||||
the global-rules query is not run at all."""
|
||||
mock_session = make_mock_session()
|
||||
mock_session.execute = AsyncMock(side_effect=[_areas(), _empty()])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||
|
||||
assert result["rules"] == []
|
||||
assert mock_session.execute.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
"""Project-scoped rules surface in the project_rules field."""
|
||||
@@ -295,9 +291,7 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
(fake_rule(id=101, topic_id=None, project_id=3, title="PR-bound",
|
||||
statement="Land schema changes in their own PR."),),
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
_empty(), _empty(), _empty(), _empty(), _empty(), proj_rules_result,
|
||||
])
|
||||
mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
@@ -311,38 +305,28 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_applicable_rules_surfaces_suppressed_with_context():
|
||||
"""Suppressed rules and topics come back with full title + rulebook context
|
||||
so the UI can render them without an extra round-trip."""
|
||||
async def test_a_co_surfaces_partner_on_another_project_is_not_dragged_in():
|
||||
"""An edge is not a way into a project: a partner that is global or on this
|
||||
project arrives; one on a different project does not."""
|
||||
mock_session = make_mock_session()
|
||||
suppressed_rules_result = MagicMock()
|
||||
suppressed_rules_result.all.return_value = [
|
||||
# (rule_id, title, topic_id, topic_title, rulebook_id, rulebook_title)
|
||||
(17, "Old rule", 5, "old-topic", 1, "FabledSword family"),
|
||||
proj_rules_result = MagicMock()
|
||||
proj_rules_result.all.return_value = [(fake_rule(id=100, topic_id=None, project_id=3),)]
|
||||
mock_session.execute = AsyncMock(side_effect=[_areas(), proj_rules_result])
|
||||
partners = [
|
||||
fake_rule(id=200, topic_id=9, project_id=None, title="global partner"),
|
||||
fake_rule(id=201, topic_id=None, project_id=3, title="same-project partner"),
|
||||
fake_rule(id=202, topic_id=None, project_id=8, title="other-project partner"),
|
||||
]
|
||||
suppressed_topics_result = MagicMock()
|
||||
suppressed_topics_result.all.return_value = [
|
||||
# (topic_id, topic_title, rulebook_id, rulebook_title)
|
||||
(22, "design-system", 1, "FabledSword family"),
|
||||
]
|
||||
mock_session.execute = AsyncMock(side_effect=[
|
||||
# sub_q, suppressed_rules_q, suppressed_topics_q, project-areas_q,
|
||||
# rules_q, proj_rules_q
|
||||
_empty(), suppressed_rules_result, suppressed_topics_result,
|
||||
_empty(), _empty(), _empty(),
|
||||
])
|
||||
|
||||
_p1, _p2, _p3 = _no_edges()
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, _p1, _p2, _p3:
|
||||
with patch("scribe.services.rulebooks.async_session") as mock_cls, \
|
||||
patch("scribe.services.rulebooks.co_surfaced_partners", AsyncMock(return_value=partners)), \
|
||||
patch("scribe.services.rulebooks.list_rule_relations", AsyncMock(return_value={})), \
|
||||
patch("scribe.services.rulebooks.list_rule_systems", AsyncMock(return_value={})):
|
||||
mock_cls.return_value = mock_session
|
||||
from scribe.services.rulebooks import get_applicable_rules
|
||||
result = await get_applicable_rules(project_id=3, user_id=7)
|
||||
|
||||
assert len(result["suppressed_rules"]) == 1
|
||||
assert result["suppressed_rules"][0]["id"] == 17
|
||||
assert result["suppressed_rules"][0]["rulebook_title"] == "FabledSword family"
|
||||
assert len(result["suppressed_topics"]) == 1
|
||||
assert result["suppressed_topics"][0]["title"] == "design-system"
|
||||
assert [r["id"] for r in result["rules"]] == [200, 201]
|
||||
|
||||
|
||||
# ── rule_brief (milestone 307) ──────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user