feat: reserve scope__ peer namespace with kind flag and guardrails

Introduce the scope peer namespace (scope__<name>) 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 <noreply@anthropic.com>
This commit is contained in:
Vineeth Voruganti 2026-07-07 15:39:52 -04:00
parent 0cb0c9abf0
commit 92897c8223
7 changed files with 288 additions and 7 deletions

View File

@ -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(

View File

@ -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:

View File

@ -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:

View File

@ -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,

View File

@ -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",

View File

@ -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
# ---------------------------------------------------------------------------

70
src/utils/scopes.py Normal file
View File

@ -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}"
)