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
+10 -14
View File
@@ -3,10 +3,9 @@
The WHY a project inherits what it does lives on projects.inception. Pure
validation is pinned here; the effects are step 3's integration tests.
The opt-out of an always-on rulebook had its own association table until
milestone 394. With no always-on tier there is nothing to opt out OF — a
rulebook binds a project only by subscription — so the table and the test
that pinned its shape both went with it.
Rules left inception in two steps: the always-on opt-out with the tier
(milestone 394), and the rulebook subscription with subscriptions (milestone
414). A rule in a rulebook is global, so there is nothing to choose.
"""
from scribe.models.project import Project
from scribe.services.inception import (
@@ -28,10 +27,9 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it():
def test_validate_inception_pins_the_choice_vocabulary():
assert CHOICE_KEYS == ("subscribe_rulebooks", "design_system_id", "seed_systems")
assert CHOICE_KEYS == ("design_system_id", "seed_systems")
assert validate_inception({}) is None
assert validate_inception({"subscribe_rulebooks": [2],
"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None
assert "must be an object" in validate_inception([])
assert "unknown inception choice" in validate_inception({"repo": "x"})
@@ -41,19 +39,17 @@ def test_validate_inception_pins_the_choice_vocabulary():
# believe a rulebook had been declined.
assert "unknown inception choice" in validate_inception(
{"exclude_always_on_rulebooks": [1]})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
# Same for the subscription choice since milestone 414.
assert "unknown inception choice" in validate_inception({"subscribe_rulebooks": [2]})
assert "positive id or null" in validate_inception({"design_system_id": 0})
assert "positive id or null" in validate_inception({"design_system_id": True})
assert "true or false" in validate_inception({"seed_systems": "yes"})
def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3]})
assert out == {"subscribe_rulebooks": [1, 3],
"design_system_id": None, "seed_systems": False}
assert normalize_choices(None) == {"subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": False}
out = normalize_choices({"design_system_id": 4})
assert out == {"design_system_id": 4, "seed_systems": False}
assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False}
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
+19 -37
View File
@@ -1,9 +1,11 @@
"""Real-Postgres integration tests for project inception (milestone 297).
What mocks can't prove: a decision's effects land through the real services
(exclusions filter the always-on set, subscriptions bind, the design system
points, the standard Systems seed once), the record is written last, a bad
target applies nothing.
(the design system points, the standard Systems seed once), the record is
written last, a bad target applies nothing.
Rules are not part of inception since milestone 414: a rule in a rulebook is
global and applies to every project, so there is nothing to subscribe to.
"""
import pytest
import pytest_asyncio
@@ -11,7 +13,6 @@ import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.services import inception as inception_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import canonical_systems as canonical_svc
from scribe.services import systems as systems_svc
from tests.helpers import ensure_user
@@ -21,8 +22,7 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
@pytest_asyncio.fixture
async def seeded():
"""Owner, a fresh project, one always-on rulebook (with a rule) and one
ordinary rulebook (with a rule)."""
"""Owner and a fresh, undecided project."""
async with async_session() as s:
owner = await ensure_user(s, "inception_owner")
project = Project(user_id=owner.id, title="Inception target")
@@ -30,56 +30,33 @@ async def seeded():
await s.flush()
ids = {"owner": owner.id, "pid": project.id}
await s.commit()
# Two ordinary rulebooks. One was flagged always-on until milestone 394
# removed the tier; a rulebook now reaches a project only by subscription,
# so what used to be "binds automatically" and "binds if you opt in" are
# the same kind of thing.
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
await rulebooks_svc.create_rule(t2.id, ids["owner"], "Write the why", "Record reasons.")
ids.update({"always": always.id, "other": other.id})
return ids
@pytest.mark.integration
async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Undecided: nothing binds, because nothing has been subscribed. Before
# milestone 394 the always-on rulebook bound here without being asked for,
# and this assertion read the other way round.
defaults = await inception_svc.current_defaults(owner, pid)
assert sorted(r["id"] for r in defaults["rulebooks"]) == sorted(
[seeded["always"], seeded["other"]])
assert set(defaults) == {"design_system_id", "design_systems", "systems"}
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
assert (await rulebooks_svc.get_applicable_rules(pid, owner))["rules"] == []
out = await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [seeded["other"]],
"design_system_id": None,
"seed_systems": True,
})
assert out["effects"]["subscribed"] == [seeded["other"]]
assert set(out["effects"]) == {"design_system_id", "systems_seeded"}
catalog = await canonical_svc.list_canonical_systems()
assert len(out["effects"]["systems_seeded"]) == len(catalog)
# Seeded Systems come out mapped, not needing a later reconciliation.
seeded_systems = await systems_svc.list_systems(owner, pid)
assert all(s.canonical_id is not None for s in seeded_systems)
# The subscription is what binds, and it is the ONLY thing that does —
# the unsubscribed rulebook contributes nothing even though it used to
# bind every project by default.
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
assert "dev is home" not in [r["title"] for r in applicable["rules"]]
# The record, written last, says why.
async with async_session() as s:
project = await s.get(Project, pid)
assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
assert project.inception["choices"]["subscribe_rulebooks"] == [seeded["other"]]
assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True}
# Re-deciding with seed again mints nothing twice.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
@@ -89,15 +66,20 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
@pytest.mark.integration
async def test_a_bad_decision_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# A subscription to a rulebook that is not yours is refused BEFORE any
# effect lands — the seed must not happen on a decision that fails.
# A design system that is not readable is refused BEFORE any effect
# lands — the seed must not happen on a decision that fails.
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [999999], "seed_systems": True,
"design_system_id": 999999, "seed_systems": True,
})
assert await systems_svc.list_systems(owner, pid) == []
# The retired rulebook choice is an unknown key, refused whole: a caller
# still passing it must not believe a rulebook was subscribed.
with pytest.raises(ValueError, match="unknown inception choice"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"subscribe_rulebooks": [1], "seed_systems": True,
})
assert await systems_svc.list_systems(owner, pid) == []
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
with pytest.raises(ValueError, match="legacy"):
await inception_svc.decide(owner, pid, via="legacy", choices={})
async with async_session() as s:
+49 -64
View File
@@ -1,21 +1,21 @@
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5,
narrowed by 394).
"""Real-Postgres tests for WHICH rules a project's listing names (milestone
307 step 5, narrowed by 394 and 414).
What mocks can't prove, and what this design must not get wrong:
A session receives rules by retrieval, scoped by a rule's home (see
test_integration_rule_scope). This is the other surface — the listing a
planning read carries — and what mocks can't prove about it:
1. A rule is invisible to a project that doesn't work in its area, and
arrives — binding, not suggested — to one that does. Area matching is
DETERMINISTIC: a tag comparison, never a similarity score.
2. A `co_surfaces` partner arrives with its other half, which is the failure
that made merging rule 144 into rule 46 look like the only fix.
3. An explicit suppression outranks an edge.
1. A global rule tagged to an area arrives in the listing of a project that
works in that area, and not before. Area matching is DETERMINISTIC: a tag
comparison, never a similarity score.
2. An UNTAGGED global rule is not listed: it applies everywhere and arrives by
retrieval, and listing every one under every project would say nothing.
3. A `co_surfaces` partner arrives with its other half — the failure that made
merging rule 144 into rule 46 look like the only fix — unless the partner
lives on a different project, because an edge is not a way in.
TWO CLAIMS WERE DROPPED HERE BY MILESTONE 394, and it is worth saying which
rather than leaving a shorter list. "A rule with no tier binds exactly as
before" and "a conditional rule is reachable, not resident" were both about
the always-on tier. There is no tier and no resident payload, so neither
states anything that can now be true or false — they were not failing, they
had stopped being claims.
Milestone 414 dropped the suppression claim ("an explicit suppression outranks
an edge") with suppressions themselves.
"""
import pytest
import pytest_asyncio
@@ -32,40 +32,27 @@ pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine"
@pytest_asyncio.fixture
async def world():
"""A project with two rulebooks — one subscribed, one not.
Both are ordinary rulebooks since milestone 394; the fixture used to flag
one always-on because that was a second, separate way to reach a project.
Keeping two is still worth it: a rulebook nobody subscribed to must
contribute nothing, and a fixture with only the subscribed one could not
tell "correctly scoped" from "returns everything".
"""
"""A project with one rule of its own, and a rulebook of global rules."""
async with async_session() as s:
owner = await ensure_user(s, "surfacing_owner")
project = Project(user_id=owner.id, title="Surfacing target")
s.add(project)
elsewhere = Project(user_id=owner.id, title="Another project")
s.add_all([project, elsewhere])
await s.flush()
ids = {"owner": owner.id, "pid": project.id}
ids = {"owner": owner.id, "pid": project.id, "elsewhere": elsewhere.id}
await s.commit()
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
)
book = await rulebooks_svc.create_rulebook(ids["owner"], "Subscribed practices")
book = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release")
plain = await rulebooks_svc.create_rule(
topic.id, ids["owner"], "Between batches, keep stacking", "Keep going.",
await rulebooks_svc.create_rule(
topic.id, ids["owner"], "dev is home", "Work on dev.",
when_to_apply="before pushing a branch",
)
await rulebooks_svc.subscribe_project(
project_id=ids["pid"], rulebook_id=book.id, user_id=ids["owner"],
own = await rulebooks_svc.create_project_rule(
ids["pid"], ids["owner"], "Between batches, keep stacking", "Keep going.",
when_to_apply="when a batch goes green",
)
ids.update({
"always": always.id, "always_topic": always_topic.id,
"book": book.id, "topic": topic.id, "plain": plain.id,
})
ids.update({"book": book.id, "topic": topic.id, "own": own.id})
return ids
@@ -74,12 +61,18 @@ async def _titles(ids) -> set[str]:
return {r["title"] for r in applicable["rules"]}
@pytest.mark.integration
async def test_an_untagged_global_rule_is_not_listed(world):
"""It applies here — and everywhere — so naming it under this project says
nothing a reader can act on. The project's own rule is always listed."""
applicable = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
assert "dev is home" not in await _titles(world)
assert [r["title"] for r in applicable["project_rules"]] == [
"Between batches, keep stacking"]
@pytest.mark.integration
async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
async def test_a_tagged_global_rule_is_listed_for_a_project_that_works_in_its_area(world):
"""The payoff: the tag match carries it in deterministically. The project
reaches the area through its own System's canonical_id — its local NAME is
irrelevant, which is the whole reason the catalog exists."""
@@ -103,7 +96,7 @@ async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
hit = [r for r in surfaced["rules"] if r["title"] == "Release tagging"]
assert hit, "a tagged conditional rule must bind a project working in that area"
assert hit, "a tagged global rule must be listed for a project working in that area"
assert [s["name"] for s in hit[0]["systems"]] == ["CI & Release"]
@@ -118,12 +111,8 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
"A name decides nothing.",
when_to_apply="when naming a build",
)
# TAGGED TO AN AREA THIS PROJECT DOES NOT WORK IN, which is what makes the
# test able to fail at all. Since milestone 394 an UNTAGGED rule in a
# subscribed rulebook applies on its own, so an untagged partner arrives
# through the ordinary query and the edge is never exercised — the
# assertion below passed while proving nothing, which is how this was
# noticed. Tagging it puts it out of reach of everything except the edge.
# Tagged to an area this project does NOT work in, so nothing but the edge
# can bring it in — otherwise this test could pass without the edge.
area = await canonical_svc.find_by_name("CI & Release")
assert area is not None, "migration 0087 seeds the standard vocabulary"
await rulebooks_svc.set_rule_systems(partner.id, world["owner"], [area.id])
@@ -132,7 +121,7 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
)
await rulebooks_svc.add_rule_relation(
world["owner"], world["plain"], partner.id, "co_surfaces",
world["owner"], world["own"], partner.id, "co_surfaces",
note="they fail together",
)
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
@@ -142,20 +131,16 @@ async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
@pytest.mark.integration
async def test_a_suppression_outranks_an_edge(world):
"""The edge says these belong together; the suppression says this project
does not want that one. An explicit decision beats an inferred one."""
# Untagged on purpose, unlike the partner above: this test is about the
# SUPPRESSION winning, so the partner should be one that would otherwise
# arrive by every available route — the ordinary query AND the edge.
partner = await rulebooks_svc.create_rule(
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
async def test_an_edge_is_not_a_way_into_another_project(world):
"""A partner that lives on a different project belongs to that project.
The edge says the two fail together; it does not make one project's rule
apply to another."""
foreign = await rulebooks_svc.create_project_rule(
world["elsewhere"], world["owner"], "Another project's rule", "Not here.",
when_to_apply="working on the other project",
)
await rulebooks_svc.add_rule_relation(
world["owner"], world["plain"], partner.id, "co_surfaces",
)
await rulebooks_svc.suppress_rule_for_project(
world["pid"], partner.id, world["owner"],
world["owner"], world["own"], foreign.id, "co_surfaces",
)
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
assert "Muted partner" not in {r["title"] for r in surfaced["rules"]}
assert "Another project's rule" not in {r["title"] for r in surfaced["rules"]}
+1 -1
View File
@@ -78,7 +78,7 @@ async def test_get_milestone_returns_body_steps_and_rules():
step = MagicMock()
step.to_dict.return_value = {"id": 9, "title": "step 1", "status": "todo"}
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
"project_rules": [{"id": 3, "title": "own"}]}
with patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone",
AsyncMock(return_value=m)), \
patch("scribe.mcp.tools.milestones.milestones_svc.get_milestone_progress",
+4 -3
View File
@@ -9,7 +9,7 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.mark.asyncio
async def test_start_planning_tool_delegates_to_service():
payload = {"milestone": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [],
payload = {"milestone": {"id": 5}, "applicable_rules": [], "project_rules": [],
"applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
with patch("scribe.mcp.tools.tasks.planning_svc.start_planning",
AsyncMock(return_value=payload)) as mock:
@@ -26,7 +26,7 @@ async def test_start_planning_tool_delegates_to_service():
@pytest.mark.asyncio
async def test_get_task_augments_plan_with_rules():
applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
"project_rules": [{"id": 3, "title": "own"}]}
with patch("scribe.mcp.tools.tasks.notes_svc.get_note_for_user",
AsyncMock(return_value=(fake_task(task_kind="plan", id=9, project_id=3), "owner"))), \
patch("scribe.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
@@ -34,7 +34,8 @@ async def test_get_task_augments_plan_with_rules():
from scribe.mcp.tools.tasks import get_task
out = await get_task(task_id=9)
assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
assert out["project_rules"] == [{"id": 3, "title": "own"}]
assert "subscribed_rulebooks" not in out
assert out["applicable_rules_truncated"] is False
+18 -21
View File
@@ -73,7 +73,7 @@ async def test_get_project_enriches_with_milestone_summary():
p = fake_project(id=5, title="found")
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
applicable_payload = {
"rules": [], "truncated": False, "subscribed_rulebooks": [],
"rules": [], "truncated": False,
}
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
@@ -91,9 +91,10 @@ async def test_get_project_enriches_with_milestone_summary():
@pytest.mark.asyncio
async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
async def test_get_project_includes_applicable_rules_and_project_rules():
"""The augmented get_project response includes applicable_rules and
subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules.
project_rules pulled from services/rulebooks.get_applicable_rules, and
nothing about subscriptions (milestone 414).
"""
p = fake_project(id=3, title="Fabled Assistant")
milestone_summary = []
@@ -105,7 +106,6 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
"rulebook_title": "FabledSword family"},
],
"truncated": False,
"subscribed_rulebooks": [{"id": 1, "title": "FabledSword family"}],
}
with patch(
"scribe.mcp.tools.projects.projects_svc.get_project",
@@ -119,8 +119,10 @@ async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
):
out = await get_project(project_id=3)
assert out["applicable_rules"][0]["title"] == "dev is home"
assert out["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
assert out["project_rules"] == []
assert out["applicable_rules_truncated"] is False
for gone in ("subscribed_rulebooks", "suppressed_rules", "suppressed_topics"):
assert gone not in out, gone
@pytest.mark.asyncio
@@ -173,7 +175,6 @@ async def test_enter_project_composes_full_context():
"topic_title": "t", "rulebook_title": "rb"}],
"project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}],
"truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "rb"}],
}
milestone_summary = [{"id": 10, "title": "MS", "status": "active", "total": 3}]
@@ -202,10 +203,9 @@ async def test_enter_project_composes_full_context():
assert out["project"] == {"id": 5, "title": "P", "status": "active", "goal": ""}
assert out["milestone_summary"] == milestone_summary
# Rules arrive in full by retrieval; the handshake lists the project's own
# by id and title and drops the subscription bookkeeping (#4045).
# by id and title (#4045), and nothing about subscriptions (milestone 414).
assert out["project_rules"] == [{"id": 99, "title": "pr1"}]
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
for gone in ("applicable_rules", "applicable_rules_truncated",
for gone in ("applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
"suppressed_rules", "suppressed_topics", "recent_notes"):
assert gone not in out, gone
assert out["open_tasks"] == [{
@@ -243,8 +243,7 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []}),
AsyncMock(return_value={"rules": [], "truncated": False}),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]),
@@ -266,8 +265,7 @@ def _enter_project_stubs(p):
patch("scribe.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p)),
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []})),
AsyncMock(return_value={"rules": [], "truncated": False})),
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[])),
patch("scribe.mcp.tools.projects.notes_svc.list_notes",
@@ -357,8 +355,7 @@ async def test_enter_project_hands_back_the_design_system_when_the_project_has_o
AsyncMock(return_value=p),
), patch(
"scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []}),
AsyncMock(return_value={"rules": [], "truncated": False}),
), patch(
"scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=[]),
@@ -417,11 +414,10 @@ async def test_create_project_with_inception_args_decides_via_mcp():
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await create_project(title="P", subscribe_rulebooks=[1], design_system_id=-1, seed_systems=True)
out = await create_project(title="P", design_system_id=-1, seed_systems=True)
kw = decide.await_args.kwargs
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
assert kw["choices"] == {"subscribe_rulebooks": [1],
"design_system_id": None, "seed_systems": True}
assert kw["choices"] == {"design_system_id": None, "seed_systems": True}
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
@@ -437,8 +433,7 @@ async def test_decide_project_inception_tool_records_an_inherit_all_decision_whe
@pytest.mark.asyncio
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
applicable = {"rules": [], "project_rules": [], "truncated": False,
"subscribed_rulebooks": []}
applicable = {"rules": [], "project_rules": [], "truncated": False}
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
async def run(project):
@@ -471,6 +466,8 @@ def test_inception_routes_and_tool_are_registered():
mcp = build_mcp_server()
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
tool = mcp._tool_manager.get_tool("create_project")
for name in ("subscribe_rulebooks", "design_system_id", "seed_systems"):
for name in ("design_system_id", "seed_systems"):
assert name in tool.parameters.get("properties", {}), name
# Rules left inception with subscriptions (milestone 414).
assert "subscribe_rulebooks" not in tool.parameters.get("properties", {})
+8 -74
View File
@@ -186,30 +186,6 @@ async def test_delete_rule_with_confirmed_soft_deletes():
assert mock_delete.called
@pytest.mark.asyncio
async def test_subscribe_project_to_rulebook_calls_service():
mock = AsyncMock()
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.subscribe_project", mock,
):
from scribe.mcp.tools.rulebooks import subscribe_project_to_rulebook
out = await subscribe_project_to_rulebook(project_id=3, rulebook_id=1)
assert out["subscribed"] is True
assert mock.called
@pytest.mark.asyncio
async def test_unsubscribe_project_from_rulebook_calls_service():
mock = AsyncMock()
with patch(
"scribe.mcp.tools.rulebooks.rulebooks_svc.unsubscribe_project", mock,
):
from scribe.mcp.tools.rulebooks import unsubscribe_project_from_rulebook
out = await unsubscribe_project_from_rulebook(project_id=3, rulebook_id=1)
assert out["subscribed"] is False
assert mock.called
def test_register_attaches_every_tool():
"""Every tool in the module reaches the server.
@@ -227,7 +203,9 @@ def test_register_attaches_every_tool():
# (milestone 399).
# 28 since milestone 394 took list_always_on_rules and the two
# always-on exclusion tools with the tier they served.
assert len(mcp.names) == 28
# 22 since milestone 414 retired subscriptions and suppressions: the two
# subscribe tools and the four suppress/unsuppress tools.
assert len(mcp.names) == 22
# spot-check a few names
assert "list_rulebooks" in mcp.names
assert "create_rule" in mcp.names
@@ -237,17 +215,17 @@ def test_register_attaches_every_tool():
# get_preference to look for here.
assert "create_preference" in mcp.names
assert "update_preference" in mcp.names
assert "subscribe_project_to_rulebook" in mcp.names
assert "create_project_rule" in mcp.names
assert "suppress_rule_for_project" in mcp.names
# milestone 312: the sweep, and the stamp that answers it
assert "rules_due_for_verification" in mcp.names
assert "mark_rule_verified" in mcp.names
# milestone 323: what a rule used to say
assert "rule_history" in mcp.names
assert "unsuppress_rule_for_project" in mcp.names
assert "suppress_topic_for_project" in mcp.names
assert "unsuppress_topic_for_project" in mcp.names
# milestone 414: a rule's home is its scope; nothing subscribes or mutes.
for gone in ("subscribe_project_to_rulebook", "unsubscribe_project_from_rulebook",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project"):
assert gone not in mcp.names
@@ -306,50 +284,6 @@ async def test_create_project_rule_uses_explicit_title_when_given():
assert kwargs["title"] == "no auto-docstrings"
@pytest.mark.asyncio
async def test_suppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
from scribe.mcp.tools.rulebooks import suppress_rule_for_project
out = await suppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_rule_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
from scribe.mcp.tools.rulebooks import unsuppress_rule_for_project
out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}
@pytest.mark.asyncio
async def test_suppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
from scribe.mcp.tools.rulebooks import suppress_topic_for_project
out = await suppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": True}
@pytest.mark.asyncio
async def test_unsuppress_topic_for_project_passes_through():
mock = AsyncMock(return_value=None)
with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
from scribe.mcp.tools.rulebooks import unsuppress_topic_for_project
out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
kwargs = mock.call_args.kwargs
assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}
# ── Typed edges between rules (milestone 307) ───────────────────────────
@pytest.mark.asyncio
+3 -6
View File
@@ -80,8 +80,7 @@ def _task(tid: int, milestone_id: int | None) -> MagicMock:
def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, systems=None,
design=None):
applicable = rules or {"rules": [], "project_rules": [], "truncated": False,
"subscribed_rulebooks": []}
applicable = rules or {"rules": [], "project_rules": [], "truncated": False}
return [
patch("scribe.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=project)),
@@ -121,8 +120,7 @@ async def test_enter_project_stays_small_however_large_the_project():
"rules": [{"id": i, "title": f"r{i}", "statement": PLAN} for i in range(50)],
"project_rules": [{"id": 100 + i, "title": f"pr{i}", "statement": PLAN,
"when_to_apply": PLAN} for i in range(60)],
"truncated": True, "subscribed_rulebooks": [{"id": 1, "title": "Family"}],
"suppressed_rules": [], "suppressed_topics": [],
"truncated": True,
}
systems = []
for i in range(40):
@@ -181,8 +179,7 @@ async def test_get_project_lists_every_milestone_without_plans():
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=_history(10))), \
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False,
"subscribed_rulebooks": []})):
AsyncMock(return_value={"rules": [], "truncated": False})):
out = await get_project(project_id=5)
assert len(out["milestone_summary"]) == 10
assert all("body" not in m for m in out["milestone_summary"])
+21 -29
View File
@@ -48,9 +48,7 @@ def test_service_signatures_require_user_id():
"set_rule_systems", "add_rule_relation", "remove_rule_relation",
"list_rules",
"get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project",
"get_applicable_rules",
):
sig = inspect.signature(getattr(svc, fn_name))
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
@@ -70,38 +68,32 @@ def test_create_project_rule_route_exists():
assert callable(getattr(rb_routes, "create_project_rule"))
def test_suppression_route_handlers_exist():
"""The 4 suppression endpoint handlers are registered as Python callables."""
from scribe.routes import rulebooks as rb_routes
for name in (
"suppress_project_rule", "unsuppress_project_rule",
"suppress_project_topic", "unsuppress_project_topic",
):
assert callable(getattr(rb_routes, name)), f"missing route handler: {name}"
def test_suppression_association_tables_declared():
"""Migration 0060 created two new association tables; the models module
must declare matching Table() objects so the rest of the service layer
can reference them via .c.<column>."""
def test_subscriptions_and_suppressions_are_gone():
"""Milestone 414: a rule's home is its scope. Nothing subscribes a project
to a rulebook or mutes a rule for one — not a route, a service function or
a table. A partial removal would leave a door that 500s on a missing table.
"""
from scribe.models import rulebook as rb_models
for tbl_name in ("project_rule_suppressions", "project_topic_suppressions"):
tbl = getattr(rb_models, tbl_name, None)
assert tbl is not None, f"models.rulebook missing {tbl_name}"
cols = {c.name for c in tbl.columns}
assert "project_id" in cols
assert "rule_id" in cols or "topic_id" in cols
from scribe.routes import rulebooks as rb_routes
from scribe.services import rulebooks as svc
for name in ("subscribe_project", "unsubscribe_project",
"suppress_project_rule", "unsuppress_project_rule",
"suppress_project_topic", "unsuppress_project_topic"):
assert not hasattr(rb_routes, name), f"route handler still present: {name}"
for name in ("subscribe_project", "unsubscribe_project",
"suppress_rule_for_project", "unsuppress_rule_for_project",
"suppress_topic_for_project", "unsuppress_topic_for_project"):
assert not hasattr(svc, name), f"service function still present: {name}"
for name in ("project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions"):
assert not hasattr(rb_models, name), f"table still declared: {name}"
def test_rule_and_subscription_handlers_callable():
def test_rule_handlers_callable():
from scribe.routes import rulebooks as rb_routes
for name in (
"list_rules", "create_rule", "get_rule", "update_rule", "delete_rule",
"subscribe_project", "unsubscribe_project", "get_project_rules",
"get_project_rules",
# The typed edges — both doors carry them (rule 33).
"relate_rules", "unrelate_rules",
):
+4 -9
View File
@@ -330,7 +330,6 @@ def test_rules_payload_records_both_the_family_and_project_halves():
"rules": [{"id": 10}, {"id": 11}],
"project_rules": [{"id": 12}],
"truncated": False,
"subscribed_rulebooks": [],
},
user_id=1,
source="enter_project",
@@ -342,9 +341,9 @@ def test_rules_payload_records_both_the_family_and_project_halves():
def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
"""The handshake's form (#4045): project rules by id and title, the
subscribed rulebooks, nothing else. A subscription-derived rule it doesn't
show must not count as surfaced."""
"""The handshake's form (#4045): project rules by id and title, nothing
else (subscriptions went in milestone 414). A global rule it doesn't show
must not count as surfaced."""
from scribe.services import rulebooks as svc
rec = MagicMock()
@@ -354,17 +353,13 @@ def test_brief_rules_payload_lists_titles_and_records_only_what_it_shows():
"rules": [{"id": 10, "title": "family", "statement": "s"}],
"project_rules": [{"id": 12, "title": "own", "statement": "s"}],
"truncated": False,
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
},
user_id=1,
source="enter_project",
brief=True,
)
assert out == {
"project_rules": [{"id": 12, "title": "own"}],
"subscribed_rulebooks": [{"id": 1, "title": "Family"}],
}
assert out == {"project_rules": [{"id": 12, "title": "own"}]}
assert rec.call_args.kwargs["rule_ids"] == [12]
+1 -16
View File
@@ -249,11 +249,7 @@ def test_the_column_guard_covers_every_table_with_a_row_helper():
# REAL table names, as _BACKED_UP holds them — not the shorter keys the
# payload uses for the same sections. Getting this wrong is what the guard
# caught on its own first run.
join_tables = {
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
"rule_systems",
}
join_tables = {"rule_systems"}
covered = set(_column_guard_targets()) | join_tables
assert set(backup._BACKED_UP) - covered == set()
# And no stale entries: every declaration must name a real target.
@@ -306,15 +302,6 @@ def test_every_table_is_either_backed_up_or_explicitly_excluded():
)
def test_join_table_row_helpers_are_pure():
subs = [SimpleNamespace(project_id=1, rulebook_id=2)]
rsup = [SimpleNamespace(project_id=1, rule_id=9)]
tsup = [SimpleNamespace(project_id=1, topic_id=7)]
assert backup._subscription_rows(subs) == [{"project_id": 1, "rulebook_id": 2}]
assert backup._rule_suppression_rows(rsup) == [{"project_id": 1, "rule_id": 9}]
assert backup._topic_suppression_rows(tsup) == [{"project_id": 1, "topic_id": 7}]
class _Result:
def scalars(self):
return self
@@ -350,8 +337,6 @@ async def test_export_full_backup_contains_every_declared_section():
# The sections v2 silently dropped, the six v5 added, v6's
# note_supersessions, and v7's code_shapes (all empty here).
for key in ("rulebooks", "rulebook_topics", "rules",
"rulebook_subscriptions", "rule_suppressions",
"topic_suppressions",
"systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings",
"note_supersessions", "code_shapes", "code_shape_events",
+3 -2
View File
@@ -13,7 +13,7 @@ async def test_start_planning_creates_milestone_and_returns_rules():
"rules": [{"id": 1, "title": "dev is home", "statement": "...",
"topic_title": "git-workflow", "rulebook_title": "FabledSword family"}],
"truncated": False,
"subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}],
"project_rules": [{"id": 3, "title": "own", "statement": "..."}],
}
with patch("scribe.services.planning.milestones_svc.create_milestone",
AsyncMock(return_value=fake_milestone)) as mock_create, \
@@ -34,7 +34,8 @@ async def test_start_planning_creates_milestone_and_returns_rules():
# Returned shape
assert out["milestone"]["id"] == 5
assert out["applicable_rules"][0]["title"] == "dev is home"
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}]
assert out["project_rules"][0]["id"] == 3
assert "subscribed_rulebooks" not in out
assert out["open_task_count"] == 3
+48 -64
View File
@@ -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) ──────────────────────────────────────────
+4 -5
View File
@@ -44,16 +44,15 @@ async def test_delete_returns_none_when_not_found():
@pytest.mark.asyncio
async def test_delete_project_cascades_to_notes_milestones_project_rules_and_suppressions():
async def test_delete_project_cascades_to_notes_milestones_and_project_rules():
session = make_mock_session()
# exists-check + 6 cascade ops:
# exists-check + 4 cascade ops:
# notes (soft) → milestones (soft) → project-scoped rules (soft) →
# project_rule_suppressions (hard DELETE) → project_topic_suppressions (hard DELETE) →
# project (soft)
# The two suppression hard-DELETEs went with their tables (milestone 414).
session.execute = AsyncMock(side_effect=[
_exists_result(True),
MagicMock(), MagicMock(), MagicMock(),
MagicMock(), MagicMock(),
MagicMock(),
])
with patch("scribe.services.trash.async_session") as cls:
@@ -61,7 +60,7 @@ async def test_delete_project_cascades_to_notes_milestones_project_rules_and_sup
from scribe.services.trash import delete
batch = await delete(user_id=1, entity_type="project", entity_id=3)
assert isinstance(batch, str)
assert session.execute.await_count == 7
assert session.execute.await_count == 5
@pytest.mark.asyncio