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:
parent
0cb0c9abf0
commit
92897c8223
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
Loading…
Reference in New Issue