feat(acl): unify the retrieval scopes; mark shared rows in Knowledge browse
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 31s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Build & push image (push) Has been skipped

Closes #2092 and the Knowledge-browse provenance gap.

The two halves of a hybrid search disagreed: the keyword half honoured shares
while the semantic half was pinned to NoteEmbedding.user_id, so a shared record
was findable by wording and invisible by meaning — the case a semantic search
exists to serve. semantic_search_notes now scopes on Note via a `scope`
parameter, and each of its five callers declares which kind of act it is:

  mcp/tools/search.py     read    the agent asked
  routes/search.py        read    the user typed it
  knowledge.py (semantic) read    matches the keyword half beside it
  plugin_context.py       browse  nobody asked; never a one-to-one share
  dedup.py                own     a verdict that blocks a write must not hinge
                                  on another person's notes

That last one is the reason this isn't a single global widening: the dedup gate
returns "update the existing one instead", so matching a stranger's record would
refuse a legitimate create and point at something the caller can't edit. Scope
defaults to "own" so a caller that forgets is wrong in the safe direction, and an
unknown scope raises rather than falling back — a typo there would be a
data-exposure bug.

Auto-inject keeps the browse scope, which still admits a collaborator's note via
a shared project. Its menu line is the only provenance an agent sees, so a
foreign hit now reads: #12 "Title" (0.71) - shared by alex, treat as a
suggestion. MCP and REST search results carry shared/owner too.

Knowledge browse: the feed hydrates cards from /api/knowledge/batch rather than
the list route, so both paths label rows now, and KnowledgeView shows "by
<owner>" on records the viewer doesn't own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
2026-07-25 23:06:52 -04:00
parent 8b069cc93f
commit ef1dbdfc86
11 changed files with 319 additions and 22 deletions
+8 -8
View File
@@ -5,7 +5,9 @@ ACL (rules #47/#78, decision note 2094): these queries were owner-only until
*found*. They now honour shares — at two different widths:
- **searching** (a `q` the caller typed) uses the full read scope, so a record
shared directly with you is findable when you go looking for it;
shared directly with you is findable when you go looking for it. BOTH halves
of the hybrid search — keyword and semantic — see equally, or a record would
be findable by wording and invisible by meaning;
- **browsing** (no `q`) and the facet counts beside it use the narrower browse
scope: your own records plus anything in a project you can reach.
@@ -174,19 +176,17 @@ async def _semantic_knowledge_search(
except Exception:
logger.warning("Keyword search failed", exc_info=True)
# 2. Semantic search — conceptual similarity.
# NOTE: this half is still OWNER-ONLY. `semantic_search_notes` scopes by
# `NoteEmbedding.user_id`, and it also backs auto-inject and the `search`
# MCP tool — so widening it would put another user's shared content into
# your agent context automatically. That's a product decision, not a bug
# fix, and it's tracked separately. Consequence until then: a shared record
# is findable here by WORDING (the keyword half above) but not by MEANING.
# 2. Semantic search — conceptual similarity, at the SAME scope as the
# keyword half above. Both halves of one search must see equally, or a shared
# record would be findable by wording and invisible by meaning — which is the
# case a semantic search exists to serve.
semantic_notes: list[Note] = []
try:
from scribe.services.embeddings import semantic_search_notes
is_task_filter = True if note_type in ("task", "plan") else (False if note_type else None)
candidates = await semantic_search_notes(
user_id=user_id,
scope="read",
query=q,
limit=min(200, limit * 4),
threshold=0.3,