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
+15
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import time
from scribe.mcp._context import current_user_id
from scribe.services.access import owner_names_for
from scribe.services.embeddings import DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes
from scribe.services.retrieval_telemetry import record_retrieval
@@ -42,6 +43,10 @@ async def search(
Returns:
{"results": [{"id", "title", "body", "is_task", "tags", "similarity"}],
"total": int}
A result marked `shared: true` with an `owner` belongs to another user —
that person's suggestion, not the operator's own record or settled practice.
Weigh it on its merits and say whose it is when you use it.
"""
uid = current_user_id()
limit = max(1, min(limit, 50))
@@ -50,6 +55,9 @@ async def search(
raw = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task,
project_id=project_id or None,
# An explicit search reaches everything the operator may read, including
# records shared with them one-to-one.
scope="read",
)
record_retrieval(
user_id=uid, source="mcp_search", query=q,
@@ -57,6 +65,9 @@ async def search(
project_id=project_id or None, is_task=is_task, results=raw,
duration_ms=(time.perf_counter() - t0) * 1000.0,
)
owners = await owner_names_for(
{int(note.user_id) for _s, note in raw if note.user_id != uid}
)
return {
"results": [
{
@@ -66,6 +77,10 @@ async def search(
"is_task": bool(note.is_task),
"tags": list(note.tags or []),
"similarity": float(score),
**(
{"shared": True, "owner": owners.get(int(note.user_id))}
if note.user_id != uid else {}
),
}
for score, note in raw
],
+7 -2
View File
@@ -5,6 +5,7 @@ from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import parse_pagination
from scribe.services.access import label_shared_items
logger = logging.getLogger(__name__)
@@ -54,7 +55,9 @@ async def list_knowledge():
)
return jsonify({
"items": items,
# Mark rows another user owns: this feed can be mixed-ownership, and an
# unmarked card reads as one the viewer wrote.
"items": await label_shared_items(uid, items),
"total": total,
"page": page,
"per_page": limit,
@@ -116,7 +119,9 @@ async def get_knowledge_batch():
from scribe.services.knowledge import get_knowledge_by_ids
items = await get_knowledge_by_ids(uid, ids)
return jsonify({"items": items})
# The scrolling feed hydrates its cards here, not from the list route, so the
# ownership markers have to be applied on this path too.
return jsonify({"items": await label_shared_items(uid, items)})
@knowledge_bp.route("/tags", methods=["GET"])
+11 -1
View File
@@ -3,6 +3,7 @@ import time
from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.services.access import owner_names_for
from scribe.services.embeddings import semantic_search_notes
from scribe.services.retrieval_telemetry import record_retrieval
@@ -36,7 +37,9 @@ async def search_route():
t0 = time.perf_counter()
results = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
# The user typed this, so it reaches everything they may read.
scope="read",
)
record_retrieval(
user_id=uid, source="rest_search", query=q,
@@ -44,6 +47,9 @@ async def search_route():
project_id=None, is_task=is_task, results=results,
duration_ms=(time.perf_counter() - t0) * 1000.0,
)
owners = await owner_names_for(
{int(note.user_id) for _s, note in results if note.user_id != uid}
)
return jsonify({
"results": [
{
@@ -53,6 +59,10 @@ async def search_route():
"is_task": note.is_task,
"tags": note.tags or [],
"similarity": score,
**(
{"shared": True, "owner": owners.get(int(note.user_id))}
if note.user_id != uid else {}
),
}
for score, note in results # semantic_search_notes returns list[tuple[float, Note]]
],
+40 -7
View File
@@ -182,6 +182,45 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
# you has not earned that standing, so it waits until you go looking for it.
# ---------------------------------------------------------------------------
def notes_visibility_clause(user_id: int, scope: str = "own"):
"""The one place a retrieval declares how far it may see.
Every path that returns notes picks a scope by what KIND of act it is:
"own" — the caller's records only. For machinery whose answer must not
depend on other people: the near-duplicate gate can't block a
create because a stranger wrote something similar, and can't
point at a record the caller cannot edit.
"browse" — own + project-reachable. For passive surfaces, where an
unrequested record would read as endorsed.
"read" — everything the ACL permits. For explicit acts: a typed search,
a fetch by id.
Defaults to the narrowest, so a new caller that forgets to choose is wrong in
the safe direction.
"""
if scope == "own":
return Note.user_id == user_id
if scope == "browse":
return browsable_notes_clause(user_id)
if scope == "read":
return readable_notes_clause(user_id)
raise ValueError(f"unknown note scope {scope!r} (own | browse | read)")
async def owner_names_for(user_ids: set[int]) -> dict[int, str]:
"""{user_id: username} in one query. Empty input costs nothing."""
if not user_ids:
return {}
async with async_session() as session:
rows = (
await session.execute(
select(User.id, User.username).where(User.id.in_(user_ids))
)
).all()
return {uid: name for uid, name in rows}
def _my_group_ids(user_id: int):
"""The caller's group ids as a SUBQUERY, not a fetched list.
@@ -267,13 +306,7 @@ async def label_shared_items(user_id: int, items: list[dict]) -> list[dict]:
}
if not foreign:
return items
async with async_session() as session:
rows = (
await session.execute(
select(User.id, User.username).where(User.id.in_(foreign))
)
).all()
names = {uid: name for uid, name in rows}
names = await owner_names_for(foreign)
for it in items:
owner_id = it.get("user_id")
if owner_id is not None and owner_id != user_id:
+5
View File
@@ -124,6 +124,11 @@ async def find_duplicate_note(
user_id, query, project_id=project_id, is_task=is_task,
orphan_only=(project_id is None),
limit=3, threshold=_SEMANTIC_THRESHOLD,
# Owner-only, deliberately: this gate BLOCKS a create and tells the
# caller to update the match instead. Matching someone else's record
# would refuse their write and point them at something they may not
# be able to edit.
scope="own",
)
for score, note in hits:
# semantic_search_notes doesn't filter note_type — enforce it here so
+17 -1
View File
@@ -19,6 +19,7 @@ from sqlalchemy import delete, select
from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding
from scribe.models.note import Note
from scribe.services.access import notes_visibility_clause
logger = logging.getLogger(__name__)
@@ -114,12 +115,21 @@ async def semantic_search_notes(
project_id: int | None = None,
is_task: bool | None = None,
orphan_only: bool = False,
scope: str = "own",
) -> list[tuple[float, Note]]:
"""Return up to *limit* (score, note) pairs most relevant to *query*.
Scores are cosine similarities in [-1, 1]; only notes at or above
*threshold* are returned, sorted highest-first.
`scope` ("own" | "browse" | "read", see access.notes_visibility_clause)
decides how far this may see. It exists because this one function serves
three different kinds of act: an explicit search, which should reach
everything the caller may read; passive auto-injection, which must not pull
an unrequested record into their context; and the near-duplicate gate, whose
verdict must not depend on other people's notes at all. Defaults to "own" so
a caller that forgets is wrong in the safe direction.
Ranking and the top-k cut happen in Postgres via pgvector's cosine-distance
operator (`<=>`, exposed as ``Vector.cosine_distance``) backed by the HNSW
index from migration 0067 — so this is an indexed ``ORDER BY ... LIMIT k``
@@ -145,11 +155,17 @@ async def semantic_search_notes(
try:
async with async_session() as session:
# Scope on Note, not NoteEmbedding.user_id: the embedding row belongs
# to the note's owner, so filtering it would pin every scope to "own"
# and leave shared records unreachable by meaning.
stmt = (
select(Note, distance.label("distance"))
.select_from(NoteEmbedding)
.join(Note, NoteEmbedding.note_id == Note.id)
.where(NoteEmbedding.user_id == user_id, Note.deleted_at.is_(None))
.where(
notes_visibility_clause(user_id, scope),
Note.deleted_at.is_(None),
)
)
if orphan_only:
stmt = stmt.where(Note.project_id.is_(None))
+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,
+18 -2
View File
@@ -25,7 +25,7 @@ from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.access import label_shared_items
from scribe.services.access import label_shared_items, owner_names_for
from scribe.services.embeddings import semantic_search_notes
from scribe.services.retrieval_telemetry import record_retrieval
from scribe.services.settings import get_setting
@@ -196,6 +196,11 @@ async def build_autoinject_hint(
threshold=cfg["threshold"],
project_id=(project_id or None),
exclude_ids=set(exclude_ids or []),
# Injection is the one retrieval nobody asked for, so it takes the BROWSE
# scope: never a record shared one-to-one with the operator. What can
# still appear is a collaborator's note inside a shared project — legible
# only because the line below names its owner.
scope="browse",
)
record_retrieval(
user_id=user_id, source="auto_inject", query=q,
@@ -210,6 +215,13 @@ async def build_autoinject_hint(
top_score = hits[0][0]
kept = [(s, n) for s, n in hits if s >= top_score - _AUTOINJECT_BAND]
# A collaborator's note can reach this menu via a shared project, and the
# operator never asked for it — so say whose it is. Unattributed, it reads as
# something they wrote and settled.
owners = await owner_names_for({
int(n.user_id) for _s, n in kept if n.user_id != user_id
})
lines = [
"> Possibly relevant from your Scribe notes — call `get_note(id)` to "
"open any in full (titles only; injected once per session):",
@@ -218,7 +230,11 @@ async def build_autoinject_hint(
for score, note in kept:
note_ids.append(int(note.id))
title = (note.title or "(untitled)").replace("\n", " ").strip()
lines.append(f"> - #{note.id} \"{title}\" ({score:.2f})")
line = f"> - #{note.id} \"{title}\" ({score:.2f})"
if note.user_id != user_id:
who = owners.get(int(note.user_id)) or "another user"
line += f" — shared by {who}, treat as a suggestion"
lines.append(line)
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}