From 92897c8223789053e6c08a4219bd15b80fd282d7 Mon Sep 17 00:00:00 2001 From: Vineeth Voruganti <13438633+VVoruganti@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:39:52 -0400 Subject: [PATCH] feat: reserve scope__ peer namespace with kind flag and guardrails Introduce the scope peer namespace (scope__) and the authoritative {"kind": "scope"} configuration flag, plus the server-side guardrails: - src/utils/scopes.py: single source of truth for the prefix, kind flag, and name helpers (scope_peer_name / is_scope_peer_name / scope_name_from_peer / validate_no_scope_peer_names) - reject reserved-prefix names on peer get-or-create (422) - reject scope peers as message authors in crud.create_messages (422) - reject scope peers as chat/representation targets (422); a scope peer as the path-level observer is deferred to Phase 2b - reject scope peers on the generic session-peer add/set/remove routes and the session-create peers mapping (422, directing to scopes routes) - peers.list excludes scope peers by default; new PeerGet.kind option ("scope" | "all") switches the view via a configuration JSONB filter - schemas: Scope / ScopeCreate / ScopeSessions(Add) and SessionCreate.scopes (unprefixed scope names, validated) Part of DEV-1997 (Scopes RFC DEV-1970). Co-Authored-By: Claude Fable 5 --- src/crud/message.py | 10 ++++ src/crud/peer.py | 24 ++++++++- src/routers/peers.py | 35 +++++++++++- src/routers/sessions.py | 32 ++++++++++- src/schemas/__init__.py | 8 +++ src/schemas/api.py | 116 +++++++++++++++++++++++++++++++++++++++- src/utils/scopes.py | 70 ++++++++++++++++++++++++ 7 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 src/utils/scopes.py diff --git a/src/crud/message.py b/src/crud/message.py index ddd6df38..e66b8f49 100644 --- a/src/crud/message.py +++ b/src/crud/message.py @@ -14,6 +14,7 @@ from src.embedding_client import embedding_client from src.telemetry.events import EmbeddingCallPurpose from src.utils.filter import apply_filter from src.utils.formatting import ILIKE_ESCAPE_CHAR, escape_ilike_pattern +from src.utils.scopes import validate_no_scope_peer_names from src.utils.types import embedding_call_purpose from src.vector_store import get_external_vector_store @@ -225,7 +226,16 @@ async def create_messages( Returns: List of created message objects + + Raises: + ValidationException: If a message is authored by a scope peer """ + # Scope peers are silent observers — they can never author messages. + validate_no_scope_peer_names( + (message.peer_name for message in messages), + action="Scope peers cannot author messages.", + ) + # Get or create session with peers in messages list peers = {message.peer_name: schemas.SessionPeerConfig() for message in messages} await get_or_create_session( diff --git a/src/crud/peer.py b/src/crud/peer.py index 21792b0f..3c7443b6 100644 --- a/src/crud/peer.py +++ b/src/crud/peer.py @@ -1,7 +1,7 @@ """CRUD helpers for peer records and peer-scoped session queries.""" from logging import getLogger -from typing import Any +from typing import Any, Literal from cashews import NOT_NONE from sqlalchemy import Select, select @@ -15,6 +15,7 @@ from src.config import settings from src.crud.workspace import get_or_create_workspace from src.exceptions import ConflictException, ResourceNotFoundException from src.models import Peer +from src.utils import scopes as scopes_util from src.utils.filter import apply_filter from src.utils.types import GetOrCreateResult @@ -214,10 +215,29 @@ async def get_peers( workspace_name: str, filters: dict[str, Any] | None = None, reverse: bool = False, + kind: Literal["scope", "all"] | None = None, ) -> Select[tuple[models.Peer]]: - """Build a filtered peer list query ordered by creation time.""" + """Build a filtered peer list query ordered by creation time. + + Args: + workspace_name: Name of the workspace + filters: Filter peers by metadata + reverse: Whether to reverse the default creation order + kind: Which kinds of peers to include. None (default) excludes scope + peers (peers whose configuration carries ``{"kind": "scope"}``), + "scope" returns only scope peers, and "all" returns everything. + """ stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name) + if kind is None: + stmt = stmt.where( + ~models.Peer.configuration.contains({"kind": scopes_util.SCOPE_KIND}) + ) + elif kind == "scope": + stmt = stmt.where( + models.Peer.configuration.contains({"kind": scopes_util.SCOPE_KIND}) + ) + stmt = apply_filter(stmt, models.Peer, filters) if reverse: diff --git a/src/routers/peers.py b/src/routers/peers.py index 90cdc1a5..b4698bd5 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -18,10 +18,15 @@ from src.crud.session import is_peer_in_session from src.dependencies import db, read_db, tracked_db from src.dialectic.chat import agentic_chat, agentic_chat_stream from src.embedding_client import embedding_client -from src.exceptions import AuthenticationException, ResourceNotFoundException +from src.exceptions import ( + AuthenticationException, + ResourceNotFoundException, + ValidationException, +) from src.security import JWTParams, require_auth from src.telemetry import prometheus_metrics from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit +from src.utils.scopes import is_scope_peer_name, validate_no_scope_peer_names from src.utils.search import search from src.utils.types import embedding_call_purpose @@ -46,7 +51,11 @@ async def get_peers( reverse: bool = Query(False, description="Whether to reverse the order of results"), db: AsyncSession = read_db, ): - """Get all Peers for a Workspace, paginated with optional filters.""" + """Get all Peers for a Workspace, paginated with optional filters. + + Scope peers are excluded by default; set `kind` to "scope" for scope peers + only, or "all" for everything. + """ filter_param = None if options and hasattr(options, "filters"): filter_param = options.filters @@ -59,6 +68,7 @@ async def get_peers( workspace_name=workspace_id, filters=filter_param, reverse=reverse, + kind=options.kind if options else None, ), ) @@ -92,6 +102,14 @@ async def get_or_create_peer( if not jwt_params.p: raise AuthenticationException("Peer ID not found in query parameter or JWT") peer.name = jwt_params.p + + # The scope namespace is reserved: scope peers are only created through + # the scopes facade (POST /workspaces/{workspace_id}/scopes). + validate_no_scope_peer_names( + [peer.name], + action="Use the scopes routes to create scopes.", + ) + result = await crud.get_or_create_peers( db, workspace_name=workspace_id, peers=[peer] ) @@ -181,6 +199,13 @@ async def chat( Query a Peer's representation using natural language. Performs agentic search and reasoning to comprehensively answer the query based on all latent knowledge gathered about the peer from their messages and conclusions. """ + # Scope peers are never observed, so no representation of them exists to + # query. (A scope peer as the path-level observer is a Phase 2b concern.) + if options.target is not None and is_scope_peer_name(options.target): + raise ValidationException( + "Scope peers cannot be a chat target: no representation is formed of a scope." + ) + # The session id arrives in the body, so require_auth can't gate on it. A # peer-scoped key may only scope a chat to a session its peer belongs to; # without this check it could read any session's messages (the dialectic @@ -278,6 +303,12 @@ async def get_representation( If a target is provided, we get the Representation of the target from the perspective of the Peer. If no target is provided, we get the omniscient Honcho Representation of the Peer. """ + # Scope peers are never observed, so no representation of them exists. + if options.target is not None and is_scope_peer_name(options.target): + raise ValidationException( + "Scope peers cannot be a representation target: no representation is formed of a scope." + ) + try: embedding: list[float] | None = None if options.search_query: diff --git a/src/routers/sessions.py b/src/routers/sessions.py index 89ce665c..f7e57b0e 100644 --- a/src/routers/sessions.py +++ b/src/routers/sessions.py @@ -24,6 +24,7 @@ from src.security import JWTParams, require_auth from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit from src.utils import summarizer from src.utils.representation import Representation +from src.utils.scopes import validate_no_scope_peer_names from src.utils.search import search from src.utils.tokens import estimate_tokens from src.utils.types import embedding_call_purpose @@ -35,6 +36,15 @@ router = APIRouter( tags=["sessions"], ) +# Guidance appended to guardrail errors when a scope peer is passed to the +# generic session-peer surface. Scope membership is managed only through the +# scopes facade so the observer mechanics stay internal. +_SCOPES_ROUTE_GUIDANCE = ( + "Scope membership is managed via the scopes routes " + "(/workspaces/{workspace_id}/scopes/{scope_id}/sessions) or the `scopes` " + "field at session creation." +) + async def _get_working_representation_task( db: AsyncSession, @@ -309,6 +319,13 @@ async def get_or_create_session( ) session.name = jwt_params.s + # Scope peers may not be added through the generic peers mapping; use the + # `scopes` field (which handles scope-peer creation and observer config). + if session.peer_names: + validate_no_scope_peer_names( + session.peer_names.keys(), action=_SCOPES_ROUTE_GUIDANCE + ) + # Handle session creation with proper error handling try: result = await crud.get_or_create_session( @@ -440,7 +457,11 @@ async def add_peers_to_session( ), db: AsyncSession = db, ): - """Add Peers to a Session. If a Peer does not yet exist, it will be created automatically.""" + """Add Peers to a Session. If a Peer does not yet exist, it will be created automatically. + + Scope peers cannot be added here; scope membership is managed via the scopes routes. + """ + validate_no_scope_peer_names(peers.keys(), action=_SCOPES_ROUTE_GUIDANCE) try: result = await crud.get_or_create_session( db, @@ -476,7 +497,10 @@ async def set_session_peers( Set the Peers in a Session. If a Peer does not yet exist, it will be created automatically. This will fully replace the current set of Peers in the Session. + + Scope peers cannot be set here; scope membership is managed via the scopes routes. """ + validate_no_scope_peer_names(peers.keys(), action=_SCOPES_ROUTE_GUIDANCE) try: await crud.set_peers_for_session( db, @@ -512,7 +536,11 @@ async def remove_peers_from_session( ), db: AsyncSession = db, ): - """Remove Peers by ID from a Session.""" + """Remove Peers by ID from a Session. + + Scope peers cannot be removed here; scope membership is managed via the scopes routes. + """ + validate_no_scope_peer_names(peers, action=_SCOPES_ROUTE_GUIDANCE) try: await crud.remove_peers_from_session( db, diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index 85d5da44..aa5dc01c 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -35,6 +35,10 @@ from src.schemas.api import ( QueueStatus, RepresentationResponse, ScheduleDreamRequest, + Scope, + ScopeCreate, + ScopeSessions, + ScopeSessionsAdd, Session, SessionBase, SessionContext, @@ -131,6 +135,10 @@ __all__ = [ "RESOURCE_NAME_PATTERN", "RepresentationResponse", "ScheduleDreamRequest", + "Scope", + "ScopeCreate", + "ScopeSessions", + "ScopeSessionsAdd", "Session", "SessionBase", "SessionContext", diff --git a/src/schemas/api.py b/src/schemas/api.py index a276c4b7..3bfa6530 100644 --- a/src/schemas/api.py +++ b/src/schemas/api.py @@ -6,7 +6,8 @@ API contract. import datetime import ipaddress -from typing import Annotated, Any, Self, cast +import re +from typing import Annotated, Any, Literal, Self, cast from urllib.parse import urlparse import tiktoken @@ -29,6 +30,11 @@ from src.schemas.configuration import ( SessionPeerConfig, WorkspaceConfiguration, ) +from src.utils.scopes import ( + SCOPE_PEER_PREFIX, + is_scope_peer_name, + scope_name_from_peer, +) from src.utils.types import DocumentLevel # --------------------------------------------------------------------------- @@ -86,6 +92,27 @@ def _validate_metadata(v: Any) -> Any: _SanitizedMetadata = Annotated[dict[str, Any], BeforeValidator(_validate_metadata)] +# Scope names are stored as peer names with the reserved prefix prepended, so +# they must leave room for the prefix within the 512-character peer name limit. +_SCOPE_NAME_MAX_LENGTH = 512 - len(SCOPE_PEER_PREFIX) + + +def _validate_scope_name(name: str) -> str: + """Validate an unprefixed scope name.""" + if not 1 <= len(name) <= _SCOPE_NAME_MAX_LENGTH: + raise ValueError( + f"Scope name must be between 1 and {_SCOPE_NAME_MAX_LENGTH} characters" + ) + if not re.fullmatch(RESOURCE_NAME_PATTERN, name): + raise ValueError(f"Scope name must match pattern {RESOURCE_NAME_PATTERN}") + if name.startswith(SCOPE_PEER_PREFIX): + raise ValueError( + "Scope name must not start with the reserved prefix " + + f"'{SCOPE_PEER_PREFIX}' (scope names are unprefixed)" + ) + return name + + # --------------------------------------------------------------------------- # Workspace schemas # --------------------------------------------------------------------------- @@ -152,6 +179,13 @@ class PeerCreate(PeerBase): class PeerGet(PeerBase): filters: dict[str, Any] | None = None + kind: Literal["scope", "all"] | None = Field( + default=None, + description=( + "Which kinds of peers to list. Omitted (default): regular peers only " + "(scope peers are excluded). 'scope': scope peers only. 'all': every peer." + ), + ) class PeerUpdate(PeerBase): @@ -328,6 +362,25 @@ class SessionCreate(SessionBase): metadata: _SanitizedMetadata | None = None peer_names: dict[str, SessionPeerConfig] | None = Field(default=None, alias="peers") configuration: SessionConfiguration | None = None + scopes: list[str] | None = Field( + default=None, + description=( + "Optional list of (unprefixed) scope names to add this session to. " + "Each scope is created if it does not exist yet. Note: scope " + "membership only affects messages ingested after the session is " + "added to the scope; backfill of pre-existing documents lands in a " + "follow-up (DEV-1999)." + ), + ) + + @field_validator("scopes") + @classmethod + def validate_scopes(cls, v: list[str] | None) -> list[str] | None: + if v is None: + return v + for scope_name in v: + _validate_scope_name(scope_name) + return v model_config = ConfigDict(populate_by_name=True) # pyright: ignore @@ -422,6 +475,67 @@ class SessionSummaries(SessionBase): ) +# --------------------------------------------------------------------------- +# Scope schemas +# --------------------------------------------------------------------------- + + +class ScopeCreate(BaseModel): + """Schema for creating (or getting) a scope by its unprefixed name.""" + + name: Annotated[str, Field(alias="id", min_length=1)] + metadata: _SanitizedMetadata | None = None + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + return _validate_scope_name(v) + + model_config = ConfigDict(populate_by_name=True) # pyright: ignore + + +class Scope(BaseModel): + """Scope response — external view of the peer backing a scope. + + The ``id`` is the unprefixed scope name; the reserved peer-name prefix is + an internal implementation detail and never surfaces here. + """ + + name: str = Field(serialization_alias="id") + h_metadata: dict[str, Any] = Field( + default_factory=dict, serialization_alias="metadata" + ) + created_at: datetime.datetime + + @field_validator("name", mode="after") + @classmethod + def strip_scope_prefix(cls, v: str) -> str: + # Constructed from Peer ORM rows whose names carry the prefix; accept + # already-unprefixed names too so manual construction works. + return scope_name_from_peer(v) if is_scope_peer_name(v) else v + + model_config = ConfigDict( # pyright: ignore + from_attributes=True, populate_by_name=True + ) + + +class ScopeSessionsAdd(BaseModel): + """Schema for adding sessions to a scope.""" + + session_ids: list[str] = Field( + ..., + min_length=1, + max_length=100, + description="IDs of existing sessions to add to the scope", + ) + + +class ScopeSessions(BaseModel): + """IDs of the sessions that are currently members of a scope.""" + + session_ids: list[str] + + # --------------------------------------------------------------------------- # Conclusion schemas # --------------------------------------------------------------------------- diff --git a/src/utils/scopes.py b/src/utils/scopes.py new file mode 100644 index 00000000..28067fbf --- /dev/null +++ b/src/utils/scopes.py @@ -0,0 +1,70 @@ +"""Scope namespace helpers. + +A *scope* is a named grouping of sessions that provides a visibility boundary +within a peer. Under the hood a scope named ``therapy`` is a peer named +``scope__therapy`` that observes its member sessions and never speaks. +Developers manage scopes exclusively through the ``/scopes`` routes (and the +``scopes`` field on session creation) and never see the observer/observed +mechanics. + +This module is the single source of truth for the reserved name prefix and +the ``kind`` configuration flag. The name prefix is the namespace; the +``{"kind": "scope"}`` flag inside the peer's configuration JSONB is the +authoritative marker that guardrails key off. +""" + +from collections.abc import Iterable +from typing import Any + +from src.exceptions import ValidationException + +# Reserved peer-name prefix for scope peers. User-created peers may not use it. +SCOPE_PEER_PREFIX = "scope__" + +# Value of the `kind` configuration flag carried by scope peers. +SCOPE_KIND = "scope" + + +def scope_peer_name(scope_name: str) -> str: + """Return the peer name backing the given (unprefixed) scope name.""" + return f"{SCOPE_PEER_PREFIX}{scope_name}" + + +def is_scope_peer_name(name: str) -> bool: + """Return whether a peer name lives in the reserved scope namespace.""" + return name.startswith(SCOPE_PEER_PREFIX) + + +def scope_name_from_peer(peer_name: str) -> str: + """Return the unprefixed scope name for a scope peer name. + + Raises: + ValueError: If the peer name is not in the scope namespace. + """ + if not is_scope_peer_name(peer_name): + raise ValueError(f"{peer_name} is not a scope peer name") + return peer_name[len(SCOPE_PEER_PREFIX) :] + + +def is_scope_peer_configuration(configuration: dict[str, Any] | None) -> bool: + """Return whether a peer configuration carries the authoritative scope flag.""" + return bool(configuration) and configuration.get("kind") == SCOPE_KIND + + +def validate_no_scope_peer_names(names: Iterable[str], *, action: str) -> None: + """Reject any peer name that uses the reserved scope namespace. + + Args: + names: Peer names to check. + action: Human-readable guidance appended to the error, directing the + caller to the supported path (e.g. the ``/scopes`` routes). + + Raises: + ValidationException: If any name starts with the reserved prefix. + """ + offenders = sorted({name for name in names if is_scope_peer_name(name)}) + if offenders: + raise ValidationException( + f"Peer name(s) {offenders} use the reserved scope prefix " + + f"'{SCOPE_PEER_PREFIX}'. {action}" + )