feat(acl): shared records are search-only and always labelled as someone else's
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 28s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Failing after 41s
CI & Build / Build & push image (push) Has been skipped

Narrows the b7d6fc7 widening per the operator's call, and fixes a regression it
introduced. Decision recorded as note 2094.

Two scopes now, deliberately different:

  readable_notes_clause  — everything the ACL permits, including records reached
                           only via a direct/group note share. For EXPLICIT acts:
                           a search the caller typed, a fetch by id.
  browsable_notes_clause — the caller's own records plus anything in a project
                           they can reach. For PASSIVE surfaces: browse lists,
                           facet counts, the process->skill manifest.

The split is a trust boundary. Anything appearing unasked — in your own list,
your own counts, or as a skill installed on your machine — reads as material you
endorsed. A one-off someone shared with you hasn't earned that standing, so it
waits until you go looking. This also dissolves the shared-Process problem by
construction rather than by special case: the manifest is a passive surface, so a
directly-shared Process is never installed as an auto-surfacing skill.

Regression fix (#2093): b7d6fc7 widened the list queries but left the fetch path
owner-only, so on the MCP path a record could be listed and then not opened —
get_snippet raised not-found, get_process couldn't resolve, and the manifest
emitted stubs whose get_process call would fail. snippets.get_snippet and
notes.resolve_process now resolve the read scope. delete_snippet gained an
explicit can_write_note guard, since being able to SEE a shared snippet must not
imply being able to bin it.

Provenance, so nothing arrives looking like the operator's own work:
- access.describe_provenance / label_shared_items add shared/owner/permission;
  labelling costs no query when everything is the caller's own.
- MCP: get_snippet, list_snippets, get_process and list_processes carry it, and
  get_process now says outright NOT to follow a shared process verbatim — its
  follow-as-written contract was the sharpest instance of the problem.
- The skill stub for a shared Process names its author and asks for a go-ahead,
  instead of describing it as "the operator's saved Scribe process".
- Policy stated once in the MCP _INSTRUCTIONS and the reusing-code skill: a
  shared record is that person's suggestion, weigh it, attribute it, ask before
  adopting it.
- UI: shared snippets show "by <owner>" in the list and a notice above the code
  in the detail view, reusing SharedWithMeView's vocabulary.

Plugin 0.1.15 -> 0.1.16 (skill text changed).

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 22:43:00 -04:00
parent b7d6fc7e5d
commit 04b58ce01e
16 changed files with 463 additions and 42 deletions
+31 -15
View File
@@ -1,10 +1,17 @@
"""Knowledge service — unified query across notes, tasks, plans, and processes.
ACL (rules #47/#78): every query here is scoped to what the caller may READ —
owned, directly shared, group-shared, or inherited from a shared project — via
`access.readable_notes_clause`. These queries were owner-only until 2026-07-25,
which meant a record shared with you could be opened by id but never *found*:
invisible in browse, in search, and in the facet counts.
ACL (rules #47/#78, decision note 2094): these queries were owner-only until
2026-07-25, which meant a record shared with you could be opened by id but never
*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;
- **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.
The asymmetry is the point. A record that appears unasked reads as one you
endorsed, so a one-off direct share has to be searched for rather than arriving
in your ambient lists.
"""
import logging
@@ -12,7 +19,7 @@ from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.services.access import readable_notes_clause
from scribe.services.access import browsable_notes_clause, readable_notes_clause
logger = logging.getLogger(__name__)
@@ -85,7 +92,9 @@ async def query_knowledge(
offset=offset, project_id=project_id,
)
visible = await readable_notes_clause(user_id)
# No query = browsing. Narrower scope: a record shared directly with the
# caller is search-only and must not appear in an ambient list.
visible = await browsable_notes_clause(user_id)
async with async_session() as session:
base = select(Note).where(visible)
@@ -141,6 +150,8 @@ async def _semantic_knowledge_search(
# 1. Keyword search — title and body ILIKE
keyword_notes: list[Note] = []
try:
# A typed query is an explicit act, so it reaches the caller's full read
# scope — including records shared directly with them.
visible = await readable_notes_clause(user_id)
async with async_session() as session:
pattern = f"%{q}%"
@@ -215,11 +226,12 @@ async def _semantic_knowledge_search(
async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list[str]:
"""Return all distinct tags across the knowledge objects this user can read.
"""Distinct tags across what this user can BROWSE.
Follows the list scope: a tag facet that omitted shared records would filter
a list down to nothing for a tag that's plainly visible in it."""
visible = await readable_notes_clause(user_id)
Follows the browse list rather than the read scope: a facet is itself a
passive surface, and offering a tag that only a search-only record carries
would filter the visible list down to nothing."""
visible = await browsable_notes_clause(user_id)
async with async_session() as session:
base = (
select(func.unnest(Note.tags).label("tag"))
@@ -232,9 +244,10 @@ async def get_knowledge_tags(user_id: int, note_type: str | None = None) -> list
async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> dict[str, int]:
"""Per-type counts for the sidebar, over what this user can read — so the
facet numbers match the list they sit beside."""
visible = await readable_notes_clause(user_id)
"""Per-type counts for the sidebar, over what this user can BROWSE — so the
numbers match the list they sit beside rather than promising rows that only a
search would surface."""
visible = await browsable_notes_clause(user_id)
async with async_session() as session:
# Count non-task types
stmt = (
@@ -302,7 +315,8 @@ async def query_knowledge_ids(
)
return [item["id"] for item in items], total
visible = await readable_notes_clause(user_id)
# Browsing (see query_knowledge) — narrower scope.
visible = await browsable_notes_clause(user_id)
async with async_session() as session:
base = select(Note.id).where(visible)
@@ -331,6 +345,8 @@ async def get_knowledge_by_ids(user_id: int, ids: list[int]) -> list[dict]:
"""Fetch full items for the given IDs, preserving the requested order."""
if not ids:
return []
# Fetching specific ids is explicit, so this takes the full read scope — the
# ids came from either a browse or a search, and both must resolve.
visible = await readable_notes_clause(user_id)
async with async_session() as session:
stmt = (