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