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
+10 -9
View File
@@ -855,7 +855,7 @@ async def _reserve_slot_for_preference(
# be indistinguishable from one that earned its place.
found = await semantic_search_rules(
user_id, query, limit=1, threshold=threshold,
kind="preference", report=_rep,
kind="preference", report=_rep, project_id=project_id or None,
)
fresh = [(s, r) for s, r in found if r.id not in already]
# ITS OWN SOURCE, and both sides of the trade logged. #2463's own finding
@@ -953,14 +953,15 @@ async def build_prompt_rule_hint(
t0 = time.perf_counter()
_rep: dict = {}
# NOT scoped to the project, and that is the corpus's own decision
# rather than an omission here — semantic_search_rules is scoped by
# OWNERSHIP on purpose, because "is there a rule about this" is asked
# across a whole rulebook. `project_id` below reaches the log row and
# nothing else.
# SCOPED TO THIS SESSION'S PROJECT (milestone 414): global rules plus
# the bound project's own. An unbound session (project_id 0) gets
# global rules only. This arm used to search every rule the user owned,
# so each project's rules were injected into every other project's
# sessions — this surface speaks unasked, and a whole-rulebook answer
# is only right for someone who asked the whole rulebook.
hits = await semantic_search_rules(
user_id, q, limit=PROMPTRULE_LIMIT, threshold=threshold,
report=_rep,
report=_rep, project_id=project_id or None,
)
duration_ms = (time.perf_counter() - t0) * 1000.0
@@ -1831,7 +1832,7 @@ async def build_write_path_hint(
hits = await semantic_search_rules(
user_id, code or path, limit=RULEHINT_LIMIT,
threshold=cfg["rule_threshold"],
report=_rep_wpr,
report=_rep_wpr, project_id=project_id or None,
)
rule_ms = (time.perf_counter() - rule_t0) * 1000.0
# BAND FIRST, dedup second, and the order is the whole point (#3851).
@@ -1986,7 +1987,7 @@ async def build_tool_rule_hint(
hits = await semantic_search_rules(
user_id, query, limit=RULEHINT_LIMIT,
threshold=cfg["tool_rule_threshold"],
report=_rep_ptr,
report=_rep_ptr, project_id=project_id or None,
)
duration_ms = (time.perf_counter() - t0) * 1000.0