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
+36 -12
View File
@@ -23,7 +23,7 @@ from sqlalchemy import delete, or_, select
from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
from scribe.models.note import Note
from scribe.services.access import notes_visibility_clause
from scribe.services.access import can_read_project, notes_visibility_clause
if TYPE_CHECKING: # resolves the Rule forward ref without importing at runtime
from scribe.models.rulebook import Rule
@@ -819,6 +819,9 @@ async def semantic_search_rules(
threshold: float = _SIMILARITY_THRESHOLD,
kind: str | None = None,
report: dict | None = None,
*,
project_id: int | None = None,
everywhere: bool = False,
) -> list[tuple[float, "Rule"]]:
"""Return up to *limit* (score, rule) pairs most relevant to *query*.
@@ -836,12 +839,26 @@ async def semantic_search_rules(
reports a decline the ranker never made (#3765). ABSENT means no search
touched the dict at all, which is a stand-in in a test, not a real call.
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or
its project. Deliberately not filtered to what currently BINDS a given
project: this answers "is there a rule about this", which a person asking
wants answered across their whole rulebook. Deciding which rules bind where
is the surfacing question, and it has its own machinery
(get_applicable_rules) rather than a second, subtly different copy here.
SCOPED, and the scope is a rule's home (milestone 414). A rule lives in a
rulebook topic — GLOBAL, it applies wherever its owner works — or on one
project, where it applies to that project and nowhere else:
- default (`project_id=None`): global rules only. A hook with no bound
project gets these, and so does any caller that forgets to say; the
safe failure is surfacing less, not another project's rules.
- `project_id=N`: global rules plus project N's own, and N's only when
the caller can read that project (access.can_read_project, so a shared
project's rules reach its collaborators too).
- `everywhere=True`: every rule the caller owns, in any home. Only for an
explicit whole-rulebook question — `search(content_type="rule")` with no
project — where "is there a rule about this" is asked across everything.
This used to be scoped by OWNERSHIP alone, on the argument that "is there
a rule about this" wants the whole rulebook. That is still right for the
explicit ask. It was wrong for the hooks, which inject unasked: every
project's rules surfaced in every other project's sessions — one repo's
template conventions arriving while editing an unrelated one — and a
project rule meant nothing a session could feel.
THERE IS NO TIER TO NARROW BY ANY MORE (milestone 394). This carried a
`tier` parameter, and the arms deliberately passed nothing: filtering on it
@@ -883,6 +900,17 @@ async def semantic_search_rules(
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
try:
# topic_id XOR project_id (migration 0059), so a rule matches exactly
# one arm of whichever clause applies. Inside the try: the access
# check reads the database too, and this function fails open.
global_rule = Rulebook.owner_user_id == user_id
if everywhere:
home = or_(global_rule, Project.user_id == user_id)
elif project_id and await can_read_project(user_id, project_id):
home = or_(global_rule, Rule.project_id == project_id)
else:
home = global_rule
async with async_session() as session:
rows = (await session.execute(
select(Rule, distance.label("distance"))
@@ -895,11 +923,7 @@ async def semantic_search_rules(
Rule.deleted_at.is_(None),
# No threshold predicate — see the note above
# semantic_search_notes. Applied below, after the collapse.
# topic_id XOR project_id, so exactly one arm can match.
or_(
Rulebook.owner_user_id == user_id,
Project.user_id == user_id,
),
home,
*( [Rule.kind == kind] if kind else [] ),
)
# Overfetch so collapsing chunks to their best row still fills