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
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:
@@ -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
|
||||
],
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]]
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user