fix(lists): shared-project records appear, and a list's q means what search means
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 28s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 28s
#2462, both decided halves. ## The ACL defect list_notes filtered on Note.user_id == user_id with no scope parameter at all — never a per-call decision, the capability was absent. query_knowledge beside it was deliberately browse-scoped with a comment saying why. So a task in a shared project was invisible in list_tasks, enter_project's open-task list, the SessionStart todo count and the web UI's task views, while the same project's notes and snippets appeared. Now the shared clause: notes_visibility_clause(user_id, "browse"). Browse and not read, per decision #2094 — an ambient list must never surface a record someone shared one-to-one; those stay search-only. This is the remaining half of a fix made twice before (#2159 widened fetch, #2092 widened meaning), and the guard below is what stops a fourth half appearing. Guarded by source inspection in test_retrieval_scopes: every list-shaped service references a shared visibility clause AND carries no bare Note.user_id comparison that would quietly re-narrow it. An owner-only list returns correct-looking rows and simply omits the shared ones — the shape no behavioural test catches. ## q is semantic (operator: "make it match") The UI's note list keyword-matched while the UI's Browse search semantic-matched, over the same records, with nothing saying so. Now one meaning: q joins the embedding index and orders by cosine distance at the interactive floor, with every lifecycle filter still applied in the same indexed query. Relevance ordering wins over `sort` when q is present — a query is a relevance claim, and sorting its results by date would shuffle the answer. ILIKE survives only as the embedder-down fallback: degraded, never empty. Stated position: superseded records are NOT demoted in list-q. The penalty reorders a top-k; reordering a paginated, counted list would make page boundaries lie. The search surfaces carry the demotion. ## The interactive floor becomes one constant INTERACTIVE_SEARCH_THRESHOLD = 0.3 in embeddings.py, consumed by routes/search.py (was a commented constant), knowledge.py (was a bare literal), and the new list-q. This closes #2463's finding 3 early — the same number lived in two files with the reasoning attached to only one. ## Deferred, deliberately The return-shape unification (ORM objects vs dicts) stays undone. It is a 14-caller refactor whose motivation — callers being unable to swap paths — shrinks now that both paths share the clause and the meaning of q. If swap pressure recurs it deserves its own change, not a rider on an ACL fix. Refs #2462, #2463
This commit is contained in:
@@ -188,10 +188,32 @@ async def list_notes(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Note], int]:
|
||||
"""Lifecycle-shaped listing. Two contracts worth knowing:
|
||||
|
||||
VISIBILITY is the shared browse clause (#47, #2462): the caller's own
|
||||
records plus anything in a project they can reach — the same reach
|
||||
query_knowledge has always had. Before this, list_notes was silently
|
||||
owner-only, so a task in a shared project was invisible in list_tasks,
|
||||
enter_project's open-task list and the web UI while the same project's
|
||||
notes appeared. Browse, not read, per decision #2094: an ambient list must
|
||||
never surface a record someone shared one-to-one — those stay search-only.
|
||||
|
||||
`q` is SEMANTIC (operator decision, 2026-08-06: "make it match") — the same
|
||||
meaning-based match as Browse search, at the interactive floor. When `q` is
|
||||
present, relevance ordering wins and `sort` is ignored; a query is a
|
||||
relevance claim and sorting its results by date would shuffle the answer.
|
||||
Falls back to ILIKE substring match only when the embedder is unavailable —
|
||||
degraded but never empty. Superseded records are not demoted here: the
|
||||
penalty reorders a top-k, and reordering a paginated, counted list would
|
||||
make page boundaries lie. The search surfaces carry the demotion.
|
||||
"""
|
||||
from scribe.services.access import notes_visibility_clause
|
||||
|
||||
visible = notes_visibility_clause(user_id, "browse")
|
||||
async with async_session() as session:
|
||||
query = select(Note).where(Note.user_id == user_id, Note.deleted_at.is_(None))
|
||||
query = select(Note).where(visible, Note.deleted_at.is_(None))
|
||||
count_query = select(func.count(Note.id)).where(
|
||||
Note.user_id == user_id, Note.deleted_at.is_(None)
|
||||
visible, Note.deleted_at.is_(None)
|
||||
)
|
||||
|
||||
# Filter by task vs note
|
||||
@@ -202,14 +224,34 @@ async def list_notes(
|
||||
query = query.where(Note.status.is_(None))
|
||||
count_query = count_query.where(Note.status.is_(None))
|
||||
|
||||
semantic_order = None
|
||||
if q:
|
||||
terms = _strip_type_nouns(q)
|
||||
for term in terms:
|
||||
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped_term}%"
|
||||
term_filter = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
|
||||
query = query.where(term_filter)
|
||||
count_query = count_query.where(term_filter)
|
||||
query_vec = None
|
||||
try:
|
||||
from scribe.services.embeddings import get_embedding
|
||||
query_vec = await get_embedding(q)
|
||||
except Exception:
|
||||
query_vec = None # embedder down → keyword fallback below
|
||||
if query_vec is not None:
|
||||
from scribe.models.embedding import NoteEmbedding
|
||||
from scribe.services.embeddings import INTERACTIVE_SEARCH_THRESHOLD
|
||||
distance = NoteEmbedding.embedding.cosine_distance(query_vec)
|
||||
sem_filter = distance <= (1.0 - INTERACTIVE_SEARCH_THRESHOLD)
|
||||
query = query.join(
|
||||
NoteEmbedding, NoteEmbedding.note_id == Note.id
|
||||
).where(sem_filter)
|
||||
count_query = count_query.join(
|
||||
NoteEmbedding, NoteEmbedding.note_id == Note.id
|
||||
).where(sem_filter)
|
||||
semantic_order = distance.asc()
|
||||
else:
|
||||
terms = _strip_type_nouns(q)
|
||||
for term in terms:
|
||||
escaped_term = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
pattern = f"%{escaped_term}%"
|
||||
term_filter = or_(Note.title.ilike(pattern), Note.body.ilike(pattern))
|
||||
query = query.where(term_filter)
|
||||
count_query = count_query.where(term_filter)
|
||||
|
||||
if tags:
|
||||
for i, tag in enumerate(tags):
|
||||
@@ -275,11 +317,15 @@ async def list_notes(
|
||||
query = query.where(paused_filter)
|
||||
count_query = count_query.where(paused_filter)
|
||||
|
||||
sort_col = getattr(Note, sort, Note.updated_at)
|
||||
if order == "asc":
|
||||
query = query.order_by(sort_col.asc())
|
||||
if semantic_order is not None:
|
||||
# A query is a relevance claim — see the docstring.
|
||||
query = query.order_by(semantic_order)
|
||||
else:
|
||||
query = query.order_by(sort_col.desc())
|
||||
sort_col = getattr(Note, sort, Note.updated_at)
|
||||
if order == "asc":
|
||||
query = query.order_by(sort_col.asc())
|
||||
else:
|
||||
query = query.order_by(sort_col.desc())
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user