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
co-authored by Claude Opus 5
parent b7d6fc7e5d
commit 04b58ce01e
16 changed files with 463 additions and 42 deletions
+97
View File
@@ -16,6 +16,7 @@ from scribe.models.group import GroupMembership
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare
from scribe.models.user import User
logger = logging.getLogger(__name__)
@@ -165,6 +166,20 @@ async def can_write_note(user_id: int, note_id: int) -> bool:
# ---------------------------------------------------------------------------
# Set-based visibility (for LIST queries)
#
# Two scopes, deliberately different (decision note 2094):
#
# readable_* — everything the ACL permits, including a record reachable ONLY
# through a direct or group note share. For EXPLICIT acts: a
# search the caller typed, or a fetch by id.
# browsable_* — the caller's own records plus anything in a project they have
# access to. For PASSIVE surfaces: browse lists, facet counts,
# the process→skill manifest.
#
# The split is a trust boundary, not an optimisation. Anything that appears
# unasked — in your own list, your own counts, or as a skill installed on your
# machine — reads as material you endorsed. A one-off record someone shared with
# you has not earned that standing, so it waits until you go looking for it.
# ---------------------------------------------------------------------------
async def readable_notes_clause(user_id: int):
@@ -201,3 +216,85 @@ async def readable_notes_clause(user_id: int):
Note.id.in_(shared_note_ids),
Note.project_id.in_(shared_project_ids),
)
async def describe_provenance(user_id: int, note) -> dict:
"""Provenance for a record being handed to a caller: `{}` when it's theirs,
otherwise `{"shared": True, "owner": <username>, "permission": <perm>}`.
Anything reaching an agent or a UI from another person has to say so. A
shared record is one person's suggestion, not a standard the caller adopted,
and without a marker the two are indistinguishable — the reader would assume
their own past self wrote it and treat it as settled practice.
"""
if note is None or note.user_id == user_id:
return {}
async with async_session() as session:
owner = await session.get(User, note.user_id)
return {
"shared": True,
"owner": owner.username if owner else None,
"permission": await get_note_permission(user_id, note.id),
}
async def label_shared_items(user_id: int, items: list[dict]) -> list[dict]:
"""Mark the entries in a list payload that belong to someone else.
Each foreign item gains `shared: True` and `owner: <username>`; the caller's
own items are left untouched so an all-mine list stays noise-free. Usernames
are resolved in one query per distinct owner, not one per row.
Lists are where provenance matters most: an unmarked row in your own list
reads as something you recorded and vetted.
"""
foreign = {
it["user_id"] for it in items
if it.get("user_id") is not None and it["user_id"] != user_id
}
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}
for it in items:
owner_id = it.get("user_id")
if owner_id is not None and owner_id != user_id:
it["shared"] = True
it["owner"] = names.get(owner_id)
return items
async def browsable_notes_clause(user_id: int):
"""A WHERE predicate for PASSIVE surfaces: the caller's own notes, plus
notes in a project they can reach (owned or shared).
Deliberately narrower than `readable_notes_clause` — it omits records the
caller can read *only* via a direct or group note share. Those stay
search-only, so a one-off someone handed them never drifts into a browse
list, a facet count, or the skill manifest as though it were their own.
Project membership is the seam because it's a standing, mutual context: if
you're on the project, its content is your working material. A note with no
project that you don't own therefore never matches — `NULL IN (...)` is not
true — which is exactly the intent.
"""
async with async_session() as session:
group_ids = await _user_group_ids(session, user_id)
project_share_conds = [ProjectShare.shared_with_user_id == user_id]
if group_ids:
project_share_conds.append(ProjectShare.shared_with_group_id.in_(group_ids))
shared_project_ids = select(ProjectShare.project_id).where(or_(*project_share_conds))
owned_project_ids = select(Project.id).where(Project.user_id == user_id)
return or_(
Note.user_id == user_id,
Note.project_id.in_(shared_project_ids),
Note.project_id.in_(owned_project_ids),
)
+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 = (
+14 -6
View File
@@ -412,15 +412,23 @@ async def convert_task_to_note(user_id: int, note_id: int) -> Note:
async def resolve_process(user_id: int, name_or_id) -> tuple[Note | None, list[dict]]:
"""Resolve a stored process by id or name.
Owner-scoped, note_type='process', non-trashed. Precedence: numeric id →
exact case-insensitive title → substring. Returns (note, other_candidates);
on a substring tie with no exact hit, `note` is the most-recently-updated
match and `other_candidates` lists the rest as [{id, title}] so the caller
can disambiguate. Returns (None, []) when nothing matches.
note_type='process', non-trashed, scoped to what this user may READ — owned
plus shared (rule #78). Naming one is an explicit act, so a Process shared
with the caller resolves here even though it is deliberately absent from the
passive process list and the skill manifest (decision note 2094); without
this, a Process a search surfaced could not then be run — see #2093.
Precedence: numeric id → exact case-insensitive title → substring. Returns
(note, other_candidates); on a substring tie with no exact hit, `note` is the
most-recently-updated match and `other_candidates` lists the rest as
[{id, title}] so the caller can disambiguate. Returns (None, []) when nothing
matches.
"""
from scribe.services.access import readable_notes_clause
visible = await readable_notes_clause(user_id)
async with async_session() as session:
base = select(Note).where(
Note.user_id == user_id,
visible,
Note.note_type == "process",
Note.deleted_at.is_(None),
)
+36 -10
View File
@@ -25,6 +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.embeddings import semantic_search_notes
from scribe.services.retrieval_telemetry import record_retrieval
from scribe.services.settings import get_setting
@@ -74,13 +75,22 @@ async def build_process_manifest(user_id: int) -> dict:
(note_type='process'). Instance-agnostic: derived from whatever Processes the
calling install owns, no operator-specific coupling.
Returns {"processes": [{id, name, slug, description}], "total": int}.
Slugs are unique within the result (a collision gets an -<id> suffix).
SCOPE: this is the most consequential passive surface Scribe has — every
entry becomes a skill file on the operator's machine that auto-surfaces and
is followed as written. It therefore uses the BROWSE scope (via the
no-query knowledge list): a Process shared directly with the operator is
never installed here, only one they own or reach through a shared project
(decision note 2094). Project-shared entries are labelled with their owner so
the stub can't pass off someone else's procedure as the operator's own.
Returns {"processes": [{id, name, slug, description, shared?, owner?}],
"total": int}. Slugs are unique within the result (collision gets -<id>).
"""
items, _ = await knowledge_svc.query_knowledge(
user_id=user_id, note_type="process", tags=[], sort="modified",
q=None, limit=100, offset=0,
)
items = await label_shared_items(user_id, items)
procs: list[dict] = []
seen: set[str] = set()
for it in items:
@@ -95,16 +105,32 @@ async def build_process_manifest(user_id: int) -> dict:
preview = " ".join((it.get("snippet") or "").split())
if len(preview) > _PROC_PREVIEW_CHARS:
preview = preview[:_PROC_PREVIEW_CHARS].rstrip() + ""
description = (
f'Run the operator\'s saved Scribe process "{title}".'
+ (f" {preview}" if preview else "")
+ f' Use when {title}-type work is requested, or when asked to run'
f' the "{title}" process.'
)
procs.append({
if it.get("shared"):
owner = it.get("owner") or "another user"
description = (
f'A shared Scribe process "{title}", authored by {owner} — NOT the'
f" operator's own."
+ (f" {preview}" if preview else "")
+ f' Use when {title}-type work is requested, or when asked to run'
f' the "{title}" process — but summarise it and get the operator\'s'
f" go-ahead before following it, since it reflects {owner}'s"
f" judgement rather than theirs."
)
else:
description = (
f'Run the operator\'s saved Scribe process "{title}".'
+ (f" {preview}" if preview else "")
+ f' Use when {title}-type work is requested, or when asked to run'
f' the "{title}" process.'
)
entry = {
"id": it["id"], "name": title, "slug": slug,
"description": description,
})
}
if it.get("shared"):
entry["shared"] = True
entry["owner"] = it.get("owner")
procs.append(entry)
return {"processes": procs, "total": len(procs)}
+19 -4
View File
@@ -295,9 +295,17 @@ async def create_snippet(
async def get_snippet(user_id: int, snippet_id: int):
"""Fetch a snippet by id, or None if it doesn't exist / isn't a snippet."""
note = await notes_svc.get_note(user_id, snippet_id)
if note is None or note.note_type != SNIPPET_NOTE_TYPE:
"""Fetch a snippet by id, or None if it doesn't exist / isn't a snippet /
isn't readable by this user.
Share-aware (rule #78): a fetch by id is an explicit act, so it resolves the
caller's full read scope rather than ownership alone. Without this, a snippet
that a search legitimately surfaced could not then be opened — see #2093."""
result = await notes_svc.get_note_for_user(user_id, snippet_id)
if result is None:
return None
note, _permission = result
if note.note_type != SNIPPET_NOTE_TYPE or note.deleted_at is not None:
return None
return note
@@ -405,15 +413,22 @@ async def update_snippet(
async def delete_snippet(user_id: int, snippet_id: int) -> bool:
"""Retire a snippet to the trash (recoverable). Returns False if the id isn't
the user's snippet.
a snippet this user may WRITE.
Recall makes this corrective, not merely tidy: a wrong or obsolete snippet
doesn't sit quietly — it keeps being offered as prior art. Removing it has to
be reachable from wherever it was recorded.
Note the explicit write check: `get_snippet` resolves the READ scope, which
now includes snippets merely shared with this user — being able to see one
must not imply being able to bin it.
"""
note = await get_snippet(user_id, snippet_id)
if note is None:
return False
from scribe.services.access import can_write_note
if not await can_write_note(user_id, snippet_id):
return False
from scribe.services.trash import delete as trash_delete
return await trash_delete(note.user_id, "note", snippet_id) is not None