Skip to content

fix(core): let scope:user knowledge follow the user across projects (#174) - #177

Merged
bejranonda merged 2 commits into
mainfrom
fix/174-user-scope-cross-project
Jul 30, 2026
Merged

fix(core): let scope:user knowledge follow the user across projects (#174)#177
bejranonda merged 2 commits into
mainfrom
fix/174-user-scope-cross-project

Conversation

@bejranonda

@bejranonda bejranonda commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Closes #174.

What

brain_teach_knowledge defaults scope to "user", but a row written from
inside a project also gets that project's ownerProjectId and a default
visibility of "project" — so it was invisible from every other project.
Symptom: "the brain has knowledge but retrieval returns nothing."

This was not a new design question

I opened #174 saying the fix needed a design call. Reading further, it didn't:
scope-filter.ts's no-active-project branch already carries exactly this
fix, added 2026-05-12 after "5/5 retrieval misses traced to this branch".
The two active-project branches never got it.

The project had already decided. The decision was applied to one branch out of three.

Fix

The same scope IN ('user','global') AND "ownerUserId" = $user disjunct, gated
behind a new opt-in includeUserScopeAcrossProjects flag (default false),
enabled only at the two personal-rule retrieval sites (kra.ts, oracle.ts).

The opt-in is load-bearing, not ceremony. Two other callers of the same
shared helper depend on the project edge holding:

Caller Why it must not widen
action-items.ts Treats the project boundary as the isolation line for tasks — explicit note from the 2026-07-10 review, finding 1
meeting-extract.ts Its supersession search is deliberately project-wide but not owner-scoped (2026-07-17, finding I2). Widening would drag the caller's other projects' decisions into a shared team surface

Flipping the shared helper for every caller is the exact mistake that file's own
comment warns about (GUIDELINES §7: give cross-scope behaviour an explicit path
rather than quietly changing a shared function).

Cross-project reach never becomes cross-user reach

  • The new disjunct carries its own ownerUserId predicate — it sits inside
    an OR, so an outer AND would not constrain the sibling arms. A test pins this.
  • Both enabling callers additionally hard-filter WHERE "ownerUserId" = $2 in the
    outer query (kra.ts, oracle.ts).
  • A separate test pins that the no-project branch reaches the same guarantee via
    its top-level AND "ownerUserId" = $n.

Measured on the live corpus (read-only)

Rows visible to a Brain Platform session
Before 104
After 141 (+37)

The 0.9009-similarity item that motivated the whole investigation
("Adding a user-facing concept or glossary page", 100% success over 11 uses)
now matches. +37 rather than +101 because the remaining Default rows are
scope='project' and correctly stay bound — narrow by design, not a bucket merge.

Also corrects the record

KNOWN_ISSUES §0p's first version named the wrong mechanism: it cited
buildRawProjectFilter (V1) and asserted that "Knowledge.visibility is a
separate Phase-4 field and does not govern this path." Production uses
buildRawProjectFilterV2, which is visibility-driven. Corrected here, with the
correction called out rather than quietly rewritten.

Test plan

  • TDD. Wrote the tests first and watched them fail: the two active-project
    cases had no scope IN clause while the no-project case already did — the
    precedent and the gap, in one run. 23/23 green after.
  • packages/core/src/__tests__/scope-filter.test.ts — 23 passing via
    npx vitest@2 run <file> (standalone; no workspace resolution needed).
    The file had zero coverage of buildRawProjectFilterV2 before this PR,
    despite it being the filter production retrieval actually uses.
  • Tests pin all three properties: closed by default, opened when opted in,
    and the disjunct pinned to ownerUserId.
  • Generated SQL inspected for all three branches (opt-in no-org, opt-in
    org, default) — parens balanced, params correct, $5,$6 IN-list intact, and
    the default branch verified to contain no scope IN clause.
  • Params unchanged by the opt-in — no new bind slots, so positional
    indices in the calling queries can't drift. Pinned by a test.
  • Verified read-only against the live DB (104 → 141) and confirmed the
    specific missing row is scope=user, visibility=project, project=Default.
  • Audited every buildRawProjectFilterV2 / buildKnowledgeWhereV2 caller
    before choosing opt-in over a blanket change.
  • Relying on CI for workspace typecheck / test / build, and in particular
    for cross-user-isolation.test.ts — the DB-gated IDOR regression net. That
    suite is the one I most want green here and cannot run locally.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rqj5y9fT3XPUqnqKyustp9

Summary by CodeRabbit

  • New Features

    • Personal knowledge can now be retrieved across projects when explicitly enabled, while remaining restricted to the owning user.
    • Cross-project visibility is supported consistently across relevant retrieval paths.
  • Bug Fixes

    • Fixed an issue where user-scoped knowledge could be missed when created in a different project.
    • Improved filtering to prevent unrelated users’ or tenants’ content from being included.
  • Documentation

    • Updated known issues to reflect the fix and its measured retrieval impact.
  • Tests

    • Added coverage for project, user, and global visibility scenarios.

…174)

brain_teach_knowledge defaults scope to "user", but a row written from
inside a project also gets that project's ownerProjectId and a default
visibility of "project" — so it was invisible from every other project.
Symptom: "the brain has knowledge but retrieval returns nothing."

This turned out NOT to be a new design question. scope-filter.ts's
no-active-project branch already carries exactly this fix, added
2026-05-12 after 5/5 retrieval misses traced to it. The two
active-project branches never got it. The project had already decided;
the decision was applied to one branch out of three.

Fix: the same `scope IN ('user','global') AND ownerUserId = $user`
disjunct, behind a new opt-in includeUserScopeAcrossProjects flag
(default false), enabled only at the two personal-rule retrieval sites
(kra.ts, oracle.ts).

Opt-in is load-bearing, not ceremony. Two other callers of the shared
helper rely on the project edge holding:
- action-items.ts treats the project boundary as the isolation line for
  tasks (2026-07-10 review, finding 1).
- meeting-extract.ts's supersession search is deliberately project-wide
  but NOT owner-scoped (2026-07-17 finding I2); widening it would drag
  the caller's other projects' decisions into a shared team surface.
Changing the shared helper for everyone is the exact mistake that
file's own comment warns about (GUIDELINES §7).

Cross-project reach never becomes cross-user reach: the new disjunct
carries its own ownerUserId predicate, and both callers already
hard-filter ownerUserId in the outer query. Tests pin both properties
plus the closed-by-default behaviour.

Measured read-only against the live corpus: rows visible to a Brain
Platform session go 104 -> 141 (+37), and the 0.9009-similarity item
that motivated the investigation now matches. +37 rather than +101
because the remaining Default rows are scope='project' and correctly
stay bound — narrow by design, not a bucket merge.

Also corrects KNOWN_ISSUES §0p, whose first version named the wrong
mechanism: it cited buildRawProjectFilter (V1) and claimed
Knowledge.visibility does not govern this path. Production uses
buildRawProjectFilterV2, which is visibility-driven.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqj5y9fT3XPUqnqKyustp9
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@bejranonda, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 97b6d300-e56d-4c7a-bfc9-e0f372101a7b

📥 Commits

Reviewing files that changed from the base of the PR and between 45f6bfc and 46072c8.

📒 Files selected for processing (2)
  • packages/core/src/__tests__/scope-filter.test.ts
  • packages/core/src/scope-filter.ts
📝 Walkthrough

Walkthrough

Adds an opt-in includeUserScopeAcrossProjects visibility flag, applies owner-constrained user/global scope branches to retrieval filters, enables it for candidate and context retrieval, and adds SQL behavior tests plus corrected issue documentation.

Changes

User-scope retrieval

Layer / File(s) Summary
Visibility filter behavior
packages/core/src/scope-filter.ts
Adds the opt-in argument and conditionally includes owner-matched user/global rows in structured and raw project filters.
Retrieval call-site opt-in
packages/core/src/kra.ts, packages/core/src/oracle.ts
Enables cross-project user-scope retrieval for candidate fetching and context construction.
Scope behavior validation and record
packages/core/src/__tests__/scope-filter.test.ts, docs/KNOWN_ISSUES.md
Tests project/org combinations, owner constraints, bind stability, and documents the fixed scope behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant fetchCandidates
  participant buildContext
  participant buildRawProjectFilterV2
  participant KnowledgeDatabase
  fetchCandidates->>buildRawProjectFilterV2: enable cross-project user scope
  buildContext->>buildRawProjectFilterV2: enable cross-project user scope
  buildRawProjectFilterV2->>KnowledgeDatabase: query owner-matched user/global rows
  KnowledgeDatabase-->>fetchCandidates: candidate rows
  KnowledgeDatabase-->>buildContext: context rows
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the main change: allowing user-scoped knowledge to follow the user across projects.
Description check ✅ Passed The description covers the change, rationale, tests, and most required template content; the Gates section is only partially represented.
Linked Issues check ✅ Passed The code matches #174 by adding an opt-in cross-project user-scope path while keeping project-bound defaults and owner-user isolation.
Out of Scope Changes check ✅ Passed The docs update and caller opt-in changes are directly tied to the same scoping fix; no unrelated code changes stand out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/174-user-scope-cross-project

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/core/src/__tests__/scope-filter.test.ts (1)

172-242: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a regression test for scope="all" in the raw-SQL filter.

buildKnowledgeWhereV2 supports includeUserScopeAcrossProjects for scope="all", but buildRawProjectFilterV2 ignores the flag in that branch. There is no current production call site that passes scope: "all" with that opt-in, but the public DataScope type allows it — a small regression assertion documenting the intended raw-SQL behavior would prevent API drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/__tests__/scope-filter.test.ts` around lines 172 - 242, The
tests only cover project scope, so add a regression case in the
buildRawProjectFilterV2 suite using args with scope set to "all" and
includeUserScopeAcrossProjects enabled. Assert the generated SQL includes the
USER_SCOPE pattern, confirming the opt-in admits user/global rows for the public
DataScope variant.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/scope-filter.ts`:
- Line 318: Apply includeUserScopeAcrossProjects consistently across every
raw-filter branch in the scope-filter query builder, including the branches
around userScopeSql and the additional branches near the other referenced
conditions. Ensure opted-in user/global rows are included for scope "all" with
accessible projects, while scope "project" without an active project excludes
them unless the flag is true, matching buildKnowledgeWhereV2 behavior.

---

Nitpick comments:
In `@packages/core/src/__tests__/scope-filter.test.ts`:
- Around line 172-242: The tests only cover project scope, so add a regression
case in the buildRawProjectFilterV2 suite using args with scope set to "all" and
includeUserScopeAcrossProjects enabled. Assert the generated SQL includes the
USER_SCOPE pattern, confirming the opt-in admits user/global rows for the public
DataScope variant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f900e58-139b-451d-8b93-c0894f46a59c

📥 Commits

Reviewing files that changed from the base of the PR and between 395a244 and 45f6bfc.

📒 Files selected for processing (5)
  • docs/KNOWN_ISSUES.md
  • packages/core/src/__tests__/scope-filter.test.ts
  • packages/core/src/kra.ts
  • packages/core/src/oracle.ts
  • packages/core/src/scope-filter.ts

Comment thread packages/core/src/scope-filter.ts Outdated
…oundary

CodeRabbit review on #177 flagged the opt-in as inconsistent. Half
right, and the half that isn't matters.

Accepted: scope="all" with an accessible-project list DID omit opted-in
user/global rows. That branch has a project boundary, so the flag
should reach it. Fixed, with a test.

Rejected: gating the no-activeProjectId branch. That branch includes
user/global rows unconditionally, and deliberately — with no active
project there is no boundary to enforce, and the behaviour predates
this flag (added 2026-05-12 after 5/5 retrieval misses traced to its
absence). Gating it would resurrect that bug for every caller that
does not opt in. The flag's doc now states exactly which branches it
governs and why the other two are excluded, rather than overclaiming a
blanket default the code never had.

Also replaces the sentinel-string construction with a small
`userScope(pUser, indent)` helper so all three call sites emit the
identical, always-user-pinned disjunct. Verified the generated SQL for
all four shapes: parens balanced, IN-list intact, and the bind index
correctly differs between the "all" ([userId, ...projects]) and
"project" ([projectId, userId, ...]) param orderings.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqj5y9fT3XPUqnqKyustp9
@bejranonda
bejranonda merged commit 30add5a into main Jul 30, 2026
6 checks passed
@bejranonda
bejranonda deleted the fix/174-user-scope-cross-project branch July 30, 2026 16:08
bejranonda added a commit that referenced this pull request Jul 31, 2026
Review pass over the v2.3.0 -> v2.5.2 arc turned up three gaps, all
introduced by that arc.

1. buildKnowledgeWhereV2 and buildRawProjectFilterV2 DISAGREED on the
   no-activeProjectId case. The raw helper admits scope IN
   ('user','global') unconditionally there — added 2026-05-12, on the
   reasoning that with no active project there is no boundary to
   enforce. The Prisma twin gated it on the new opt-in flag. They back
   the same visibility rule on different query surfaces, so a caller's
   results depended on which one their code path happened to hit. Same
   inconsistency class CodeRabbit caught inside the raw helper on #179
   — fixed there, and introduced between the two.

2. buildKnowledgeWhereV2 had ZERO test coverage despite backing the
   knowledge listing route and action-items, and #177 added an
   unreachable branch to it (no caller passes the flag; kra/oracle use
   the raw helper). Six tests now pin: closed by default under an
   active project, opened when opted in, applied to scope="all",
   unconditional with no active project — asserted against the raw
   helper in the same test so they cannot drift apart again — and the
   disjunct pinned to ownerUserId.

3. Documented the behaviour change the parity fix causes at a
   security-reviewed call site. action-items.ts passes projectId as
   string | null; with null it now admits the caller's own user-scoped
   rows. Acceptable — accessibleProjectIds stays [], the query ANDs
   type: action_item, it is pinned to ownerUserId, and action items are
   project-scoped by contract so the set is empty — but it is a real
   change at a spot with an explicit isolation note, so it is stated
   rather than left to be rediscovered.

Also notes on Limit.max that 0 blocks everything and is not a
"disabled" sentinel. Before the atomic rewrite the fresh-bucket path
returned ok:true unconditionally, so max:0 leaked one request per
window; that inconsistency is gone and nothing depended on it.


Claude-Session: https://claude.ai/code/session_01Rqj5y9fT3XPUqnqKyustp9

Co-authored-by: Claude <noreply@anthropic.com>
bejranonda added a commit that referenced this pull request Jul 31, 2026
…ct a false claim

CI failed on scope-filter-v2.test.ts, and it caught two things.

1. The test asserted the OLD object shape for scope="all" with an empty
   accessible list. Its intent — "no proj+org IN-list branch when there
   are no accessible projects" — is preserved and now asserted on the
   semantic instead: the helper returns the flat owner catch-all.
   Verified this is the right direction rather than assuming: V1
   buildKnowledgeWhere has ALWAYS done `return base` here
   (scope-filter.ts:62), the raw V2 helper does the same, and
   KNOWLEDGE §12.19 documents ?scope=all as "everything the user owns".
   The old Prisma V2 was the lone outlier. Only that one test failed;
   the other scope="all" tests pass a non-empty list and are untouched.

2. It refuted a claim I published in #180 and in the docs: that
   buildKnowledgeWhereV2 had ZERO test coverage. It does not — a second
   file, scope-filter-v2.test.ts, covers it in 22 places. I had run
   `grep -c` against scope-filter.test.ts alone and generalised from
   one file to "no coverage anywhere".

Retracted in KNOWN_ISSUES §0p and APPROACH §5bf rather than quietly
softened. What was actually true is narrower: the #177 branch was
unreachable because no caller passed the flag, and the cross-helper
case had no test in either file. Both still worth fixing; neither is
"zero coverage".

The corrected lesson is smaller and sharper than the one first written:
a coverage claim from a single-file grep is not a coverage claim, and
reaching for the more dramatic version of a finding is its own failure
mode — the same one APPROACH §5bf opens by describing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqj5y9fT3XPUqnqKyustp9
bejranonda added a commit that referenced this pull request Jul 31, 2026
…ser owns (#181)

* fix(core): scope="all" with no org context must return everything the user owns

Second review pass over the #174 arc. One more helper divergence, this
one pre-existing and unrelated to #174 — it had simply never been
looked at.

buildKnowledgeWhereV2 and buildRawProjectFilterV2 express ONE policy on
two query surfaces. Under scope="all" with an empty accessible-project
list they disagreed:

  raw    -> ownerUserId = $user AND deletedAt IS NULL   (everything owned)
  prisma -> (visibility='private' AND owner) OR (ownerProjectId IS NULL AND owner)

So the Prisma path dropped the user's own `visibility: 'project'` rows —
the DEFAULT visibility, i.e. most of them. That contradicts the
documented ?scope=all contract in KNOWLEDGE §12.19 ("shows everything
the authenticated user owns across all projects"), and is reachable
whenever getAccessibleProjectIds() short-circuits to [] for a
non-member (org.ts:1034).

Fail-safe — it under-reported, it never leaked another user's rows — but
wrong, and invisible because each helper looked correct in isolation.

Fixed by returning the same catch-all the raw helper already used, and
pinned by a test that asserts BOTH helpers in the same case. That
assertion style is the actual lesson: a per-function test cannot see a
gap that lives between two functions, and "both individually correct"
is how such a gap survives review.

Docs swept for the two parity fixes (this one and #180's):
- GUIDELINES invariant 3: the opt-in governs branches that HAVE a
  boundary; with no activeProjectId it is unconditional. Adds the
  change-them-together rule for the helper pair.
- KNOWLEDGE: scope table and §12.19 now state that the Prisma listing
  helper applies the identical rule, that "retrieval wider than
  listing" holds only where an active project supplies a boundary, and
  that ?scope=all with no org context returns everything owned.
- KNOWN_ISSUES §0p: new row covering both divergences plus the
  zero-coverage finding on buildKnowledgeWhereV2.
- APPROACH §5bf: "the fix is part of the system too — review it like
  one", and check coverage before adding to something.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqj5y9fT3XPUqnqKyustp9

* fix(core): update the all-scope test to the corrected contract; retract a false claim

CI failed on scope-filter-v2.test.ts, and it caught two things.

1. The test asserted the OLD object shape for scope="all" with an empty
   accessible list. Its intent — "no proj+org IN-list branch when there
   are no accessible projects" — is preserved and now asserted on the
   semantic instead: the helper returns the flat owner catch-all.
   Verified this is the right direction rather than assuming: V1
   buildKnowledgeWhere has ALWAYS done `return base` here
   (scope-filter.ts:62), the raw V2 helper does the same, and
   KNOWLEDGE §12.19 documents ?scope=all as "everything the user owns".
   The old Prisma V2 was the lone outlier. Only that one test failed;
   the other scope="all" tests pass a non-empty list and are untouched.

2. It refuted a claim I published in #180 and in the docs: that
   buildKnowledgeWhereV2 had ZERO test coverage. It does not — a second
   file, scope-filter-v2.test.ts, covers it in 22 places. I had run
   `grep -c` against scope-filter.test.ts alone and generalised from
   one file to "no coverage anywhere".

Retracted in KNOWN_ISSUES §0p and APPROACH §5bf rather than quietly
softened. What was actually true is narrower: the #177 branch was
unreachable because no caller passed the flag, and the cross-helper
case had no test in either file. Both still worth fixing; neither is
"zero coverage".

The corrected lesson is smaller and sharper than the one first written:
a coverage claim from a single-file grep is not a coverage claim, and
reaching for the more dramatic version of a finding is its own failure
mode — the same one APPROACH §5bf opens by describing.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqj5y9fT3XPUqnqKyustp9

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scope: "user" knowledge is invisible outside its capture project (117 rows, ~half the corpus)

1 participant