feat(rules): retrieval honours a rule's home — global everywhere, a project's rules only in that project (#4074)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 31s

semantic_search_rules searched every rule the user owned, and every hook arm
called it without a project, so each project's rules were injected into every
other project's sessions and a project rule meant nothing a session could feel.

The search now takes a scope: global rules by default (an unbound session, or a
caller that forgets to say), global plus project N when given project_id (N's
rules only if the caller can read that project, through access.can_read_project),
and every owned rule with everywhere=True. The four hook arms and the report
preference lookup pass the session's project; an explicit
search(content_type="rule") scopes to its project_id, or asks the whole rulebook
without one.

Milestone 414 step 1. Guarded by an AST walk that every hook call site passes
project_id, and an integration test on real Postgres that a rule is reached only
from its home.

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:06:34 -04:00
co-authored by Claude Opus 5
parent 7f974d9749
commit 188e78bbcd
7 changed files with 255 additions and 28 deletions
+19
View File
@@ -87,3 +87,22 @@ async def test_fable_search_limit_is_clamped():
mock_search.reset_mock()
await search(q="x", limit=0)
assert mock_search.call_args.kwargs["limit"] == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("project_id, scope", [
(5, {"project_id": 5}),
# No project: the explicit question is asked of the whole rulebook.
(0, {"everywhere": True}),
])
async def test_rule_search_scopes_to_the_project_it_is_given(project_id, scope):
"""With a project: global rules plus that project's (milestone 414).
Without one: every rule, because an unscoped "is there a rule about this"
is asking the whole rulebook — unlike a hook, which speaks unasked."""
_user_id_ctx.set(7)
found = AsyncMock(return_value=[])
with patch("scribe.mcp.tools.search.semantic_search_rules", found):
await search(q="release tagging", content_type="rule", project_id=project_id)
kwargs = found.await_args.kwargs
assert {k: kwargs[k] for k in scope} == scope
assert set(kwargs) & {"project_id", "everywhere"} == set(scope)