feat(rules): tier 1 preloads, tier 2 arrives by area — and a split rule can no longer be read half-way (#3031, milestone 307 step 5)
CI & Build / Python lint (push) Failing after 6s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Failing after 26s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Skipped

The payoff step: a rule stops having to be resident to be honoured.

- list_always_on_rules returns the ALWAYS-ON tier only. It is the session-start
  call, made before any project is in scope, so there is no area vocabulary to
  match a conditional rule against yet.
- get_applicable_rules carries a conditional rule when the project works in an
  area the rule is tagged to — resolved through systems.canonical_id, so the
  project's own NAME for the area is irrelevant, which is the entire reason the
  catalog exists. The gate is applied IN SQL, so `limit` counts rules that will
  actually surface rather than rules about to be dropped.
- Bindingness is a deterministic TAG match, never a similarity score (D7). The
  vector channel stays a suggestion, in search.

co_surfaced_partners is the fix that rule 144 never had. It was split off rule
46 and folded back the same day because "either rule could surface without the
other and miss exposing a project to what the entire shape is intended to be" —
correct, and merging was the only remedy available. Now a partner ARRIVES with
its other half even when nothing else selected it, tagged `via: co_surfaces` so
the payload says why. Two limits, both deliberate: only rules the caller owns,
because an edge is not a back door into someone else's rulebook; and a
project's suppressions are passed as exclusions, because an explicit mute is a
decision and an edge does not outrank it.

COMPATIBILITY, asserted first in the integration test rather than reasoned
about: a rule with no tier, no areas and no edges binds exactly as it did
before any of this existed. `tier` defaults to always_on, so an install
upgrades and every rule it already had keeps arriving. Getting that backwards
would silently stop enforcing rules people rely on, which is worse than any
amount of payload bloat.

Four unit tests were coupled to the ORDER of a mocked session's execute()
calls, so a new query broke them. Rather than pad the sequence and deepen that
coupling, the three post-query lookups are stubbed by name — they have their
own coverage, and the real wiring is proven against Postgres.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 15:10:41 -04:00
co-authored by Claude Opus 5
parent 6ada97bb0b
commit cd9aa87aa4
4 changed files with 334 additions and 10 deletions
+36 -9
View File
@@ -198,6 +198,25 @@ def _empty():
return r
def _no_edges():
"""Silence the three post-query lookups get_applicable_rules now makes.
They are separate service functions with their own coverage (and the real
wiring is proven against Postgres in test_integration_rule_surfacing), so
stubbing them here keeps each of these tests about the one projection it
was written to check — rather than about the order a mocked session's
execute() calls happen to arrive in.
"""
return (
patch("scribe.services.rulebooks.co_surfaced_partners",
AsyncMock(return_value=[])),
patch("scribe.services.rulebooks.list_rule_relations",
AsyncMock(return_value={})),
patch("scribe.services.rulebooks.list_rule_systems",
AsyncMock(return_value={})),
)
@pytest.mark.asyncio
async def test_get_applicable_rules_returns_shape():
"""get_applicable_rules returns the full projection — including the
@@ -213,12 +232,14 @@ 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, rules_q, proj_rules_q
# Execute order: sub_q, suppressed_rules_q, suppressed_topics_q,
# project-areas_q (milestone 307), rules_q, proj_rules_q
mock_session.execute = AsyncMock(side_effect=[
sub_result, _empty(), _empty(), rules_result, _empty(),
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
])
with patch("scribe.services.rulebooks.async_session") as mock_cls:
_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, limit=50)
@@ -250,10 +271,11 @@ async def test_get_applicable_rules_truncates_when_over_limit():
(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(), rules_result, _empty(),
sub_result, _empty(), _empty(), _empty(), rules_result, _empty(),
])
with patch("scribe.services.rulebooks.async_session") as mock_cls:
_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, limit=50)
@@ -274,10 +296,11 @@ async def test_get_applicable_rules_includes_project_scoped_rules():
statement="Land schema changes in their own PR."),),
]
mock_session.execute = AsyncMock(side_effect=[
_empty(), _empty(), _empty(), _empty(), proj_rules_result,
_empty(), _empty(), _empty(), _empty(), _empty(), proj_rules_result,
])
with patch("scribe.services.rulebooks.async_session") as mock_cls:
_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)
@@ -303,10 +326,14 @@ async def test_get_applicable_rules_surfaces_suppressed_with_context():
(22, "design-system", 1, "FabledSword family"),
]
mock_session.execute = AsyncMock(side_effect=[
_empty(), suppressed_rules_result, suppressed_topics_result, _empty(), _empty(),
# 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(),
])
with patch("scribe.services.rulebooks.async_session") as mock_cls:
_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)