fix(scopes): make scope identity unforgeable, unblock non-pattern peer names

Three coupled changes to the scopes facade.

1. `PeerCreate` no longer gates internal lookups. It exists to validate a new,
   user-supplied peer id at the API boundary, but crud used it as a DTO for
   names that already exist, so any name outside RESOURCE_NAME_PATTERN raised a
   raw pydantic ValidationError — which is not a HonchoException, so it fell
   through to the catch-all handler as an HTTP 500. Adds `PeerSpec` (same
   fields, no charset pattern) as `PeerCreate`'s base, widens
   `get_or_create_peers` to accept it, and changes `get_peer` to take a plain
   str. All 13 construction sites converted; the create route keeps full
   validation.

   This unbreaks the Dreamer: DreamScheduler passes `collection.observer`
   straight into the specialist preflight, and scope peers have
   `observe_others=true`, so every `(scope.x, peer)` dream died there — the
   feature scopes exist to enable. It also fixes a pre-existing bug unrelated
   to scopes: a peer named `alice.smith` (legal before d429de0e5338, which
   validated names by length alone) 500s on message create, session peer add,
   and peer update.

2. The `kind` flag moves from `configuration` to `internal_metadata`.
   `configuration` is user-writable — `PeerCreate`/`PeerUpdate` accept a
   free-form dict and `update_peer` replaces it wholesale — so a legitimate
   `{"observe_me": true}` update silently dropped the flag, and a forged
   `{"kind": "scope"}` injected an ordinary peer into `POST /scopes/list`.
   `internal_metadata` appears in no API schema. `observe_me: false` stays in
   `configuration`, where it belongs.

3. Scope identity requires prefix AND flag, via `is_scope_peer()` and
   `scope_peer_clause()`. Neither half is forgeable: the prefix sits outside
   RESOURCE_NAME_PATTERN, `internal_metadata` is unreachable. Usage-site guards
   become flag-based so a legacy peer merely occupying the namespace keeps
   working rather than 422-ing on its own traffic; peer create and update stay
   name-based, since those must stop new names entering the namespace.
   `update_peer` now returns 422 instead of 500.

Also swaps the reserved prefix from `scope__` to `scope.`: `_` is inside
RESOURCE_NAME_PATTERN, so any tenant could already own a `scope__x` peer.

No DB migration — `internal_metadata` already exists on `peers`, and no scope
peers exist in any deployment yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vineeth Voruganti 2026-07-29 12:07:17 -04:00
parent 679aa25388
commit 48047a6a99
17 changed files with 476 additions and 94 deletions

View File

@ -39,6 +39,7 @@ from .peer import (
get_peer,
get_peers,
get_sessions_for_peer,
reject_scope_peers,
update_peer,
)
from .peer_card import get_peer_card, set_peer_card
@ -122,6 +123,7 @@ __all__ = [
# Peer
"get_or_create_peers",
"get_peer",
"reject_scope_peers",
"get_peers",
"update_peer",
"get_sessions_for_peer",

View File

@ -952,7 +952,7 @@ async def create_observations(
# Validate all peers exist
for peer_name in peers_to_validate:
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
await get_peer(db, workspace_name, peer_name)
# Get or create all collections
for observer, observed in collection_pairs:

View File

@ -14,10 +14,10 @@ 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
from .peer import reject_scope_peers
from .session import get_or_create_session
logger = getLogger(__name__)
@ -317,8 +317,14 @@ async def create_messages(
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(
# Scope peers are silent observers — they can never author messages. Keyed
# off name+flag so a legacy peer merely occupying the reserved namespace
# keeps ingesting. Must stay *before* get_or_create_session below: that call
# would create the scope peer and add it with a default SessionPeerConfig(),
# clobbering its observe_others=True/observe_me=False membership config.
await reject_scope_peers(
db,
workspace_name,
(message.peer_name for message in messages),
action="Scope peers cannot author messages.",
)

View File

@ -1,10 +1,11 @@
"""CRUD helpers for peer records and peer-scoped session queries."""
from collections.abc import Iterable
from logging import getLogger
from typing import Any, Literal
from cashews import NOT_NONE
from sqlalchemy import Select, select
from sqlalchemy import ColumnElement, Select, and_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import make_transient_to_detached
@ -13,7 +14,11 @@ from src import models, schemas
from src.cache.client import cache, get_cache_namespace, safe_cache_delete
from src.config import settings
from src.crud.workspace import get_or_create_workspace
from src.exceptions import ConflictException, ResourceNotFoundException
from src.exceptions import (
ConflictException,
ResourceNotFoundException,
ValidationException,
)
from src.models import Peer
from src.utils import scopes as scopes_util
from src.utils.filter import apply_filter
@ -37,10 +42,63 @@ def peer_cache_key(workspace_name: str, peer_name: str) -> str:
)
def scope_peer_clause() -> ColumnElement[bool]:
"""SQL form of ``is_scope_peer()``: reserved name prefix AND the internal kind flag.
Lives here rather than in ``crud/scope.py`` because that module already imports
from this one, and ``get_peers`` below needs the clause the other direction
would be a cycle.
``autoescape=True`` is future-proofing: '.' is not a LIKE wildcard, but '_' is,
so under a ``scope__``-style prefix an unescaped ``startswith`` would also match
``scopeXY...``. Both columns are NOT NULL with defaults, so the negation
``~scope_peer_clause()`` has no NULL-semantics trap.
"""
return and_(
models.Peer.name.startswith(scopes_util.SCOPE_PEER_PREFIX, autoescape=True),
models.Peer.internal_metadata.contains({"kind": scopes_util.SCOPE_KIND}),
)
async def reject_scope_peers(
db: AsyncSession,
workspace_name: str,
names: Iterable[str],
*,
action: str,
) -> None:
"""Reject peers that really are scopes, keyed off name AND flag.
Unlike a pure name check, a legacy peer that merely occupies the reserved
namespace (names were length-only validated before migration
``d429de0e5338``, so ``scope.production`` is a possible user name) keeps
working normally instead of being locked out of its own data.
Costs nothing on the common path: with no reserved-prefix name in ``names``
there is no query at all.
Raises:
ValidationException: If any name resolves to a real scope peer.
"""
candidates = sorted({n for n in names if scopes_util.is_scope_peer_name(n)})
if not candidates:
return
result = await db.execute(
select(models.Peer.name)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name.in_(candidates))
.where(scope_peer_clause())
)
offenders = sorted(row[0] for row in result.all())
if offenders:
raise ValidationException(f"Peer name(s) {offenders} are scopes. {action}")
async def get_or_create_peers(
db: AsyncSession,
workspace_name: str,
peers: list[schemas.PeerCreate],
peers: list[schemas.PeerSpec],
*,
_retry: bool = False,
_pending_invalidation: list[str] | None = None,
@ -200,15 +258,20 @@ async def _fetch_peer(
async def get_peer(
db: AsyncSession,
workspace_name: str,
peer: schemas.PeerCreate,
peer_name: str,
) -> models.Peer:
"""
Get an existing peer.
Takes a plain name, not a create schema: this is a pure read, and validating
an already-existing name against ``PeerCreate``'s charset pattern turns a
lookup into a raw pydantic ValidationError (an HTTP 500) for legacy dotted
names and every ``scope.``-prefixed peer.
Args:
db: Database session
workspace_name: Name of the workspace
peer: Peer creation schema
peer_name: Name of the peer
Returns:
The peer if found
@ -216,10 +279,10 @@ async def get_peer(
Raises:
ResourceNotFoundException: If the peer does not exist
"""
data = await _fetch_peer(db, workspace_name, peer.name)
data = await _fetch_peer(db, workspace_name, peer_name)
if data is None:
raise ResourceNotFoundException(
f"Peer {peer.name} not found in workspace {workspace_name}"
f"Peer {peer_name} not found in workspace {workspace_name}"
)
# Reconstruct ORM object from cached dict and merge into session
@ -243,19 +306,16 @@ async def get_peers(
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.
peers (see ``scope_peer_clause``: reserved name prefix AND the
``{"kind": "scope"}`` internal_metadata flag), "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})
)
stmt = stmt.where(~scope_peer_clause())
elif kind == "scope":
stmt = stmt.where(
models.Peer.configuration.contains({"kind": scopes_util.SCOPE_KIND})
)
stmt = stmt.where(scope_peer_clause())
stmt = apply_filter(stmt, models.Peer, filters)
@ -288,7 +348,7 @@ async def update_peer(
the peer
"""
peers_result = await get_or_create_peers(
db, workspace_name, [schemas.PeerCreate(name=peer_name)]
db, workspace_name, [schemas.PeerSpec(name=peer_name)]
)
honcho_peer = peers_result.resource[0]

View File

@ -38,7 +38,7 @@ async def get_peer_card(
Raises:
ResourceNotFoundException: If the peer does not exist.
"""
peer = await get_peer(db, workspace_name, schemas.PeerCreate(name=observer))
peer = await get_peer(db, workspace_name, observer)
return cast(
list[str] | None,
peer.internal_metadata.get(
@ -70,7 +70,7 @@ async def set_peer_card(
"""
# Ensure the peer exists (get-or-create)
peers_result = await get_or_create_peers(
db, workspace_name, [schemas.PeerCreate(name=observer)]
db, workspace_name, [schemas.PeerSpec(name=observer)]
)
stmt = (

View File

@ -1,8 +1,10 @@
"""CRUD helpers for scopes.
A scope is a named grouping of sessions, implemented as a peer named
``scope__<name>`` with configuration ``{"kind": "scope", "observe_me": false}``
that observes its member sessions (``observe_others=true``) and never speaks.
``scope.<name>`` carrying ``{"kind": "scope"}`` in ``internal_metadata`` (the
authoritative, user-unwritable flag) and ``{"observe_me": false}`` in
``configuration``, that observes its member sessions (``observe_others=true``)
and never speaks.
See ``src/utils/scopes.py`` for the namespace helpers.
Membership only affects messages ingested *after* a session is added to a
@ -21,21 +23,29 @@ from src.cache.client import safe_cache_delete
from src.exceptions import ConflictException, ResourceNotFoundException
from src.utils.scopes import (
SCOPE_KIND,
is_scope_peer_configuration,
is_scope_peer,
scope_peer_name,
)
from src.utils.types import GetOrCreateResult
from .peer import peer_cache_key
from .peer import peer_cache_key, scope_peer_clause
from .workspace import get_or_create_workspace
logger = getLogger(__name__)
# Peer-level configuration stamped on every scope peer at creation. `kind` is
# the authoritative scope flag; `observe_me: false` ensures no representation
# is ever formed *of* a scope peer.
SCOPE_PEER_CONFIGURATION: dict[str, str | bool] = {
# Internal metadata stamped on every scope peer at creation. `kind` is the
# authoritative scope flag and lives here — NOT in `configuration` — because
# `configuration` is user-writable (`PeerCreate`/`PeerUpdate` accept a free-form
# dict, and `update_peer` replaces it wholesale), so a user could forge or clear
# the flag. `internal_metadata` appears in no API schema at all.
SCOPE_PEER_INTERNAL_METADATA: dict[str, str] = {
"kind": SCOPE_KIND,
}
# Peer-level configuration stamped on every scope peer at creation.
# `observe_me: false` ensures no representation is ever formed *of* a scope peer.
# This one stays user-visible: `observe_me` is a legitimate config knob.
SCOPE_PEER_CONFIGURATION: dict[str, str | bool] = {
"observe_me": False,
}
@ -93,7 +103,7 @@ async def get_or_create_scopes(
changed_peers: list[models.Peer] = []
for existing_peer in existing_peers:
if not is_scope_peer_configuration(existing_peer.configuration):
if not is_scope_peer(existing_peer.name, existing_peer.internal_metadata):
raise ConflictException(
f"A peer named '{existing_peer.name}' already exists in workspace "
+ f"{workspace_name} but is not a scope. Rename or delete that "
@ -113,6 +123,7 @@ async def get_or_create_scopes(
workspace_name=workspace_name,
name=name,
h_metadata=scope_schema.metadata or {},
internal_metadata=dict(SCOPE_PEER_INTERNAL_METADATA),
configuration=dict(SCOPE_PEER_CONFIGURATION),
)
for name, scope_schema in peer_names.items()
@ -161,11 +172,16 @@ async def get_scopes(
workspace_name: str,
reverse: bool = False,
) -> Select[tuple[models.Peer]]:
"""Build a scope list query (peers with the scope kind flag) ordered by creation time."""
"""Build a scope list query, ordered by creation time.
Requires both halves via ``scope_peer_clause`` (reserved name prefix AND the
internal kind flag), so a peer carrying a forged ``configuration`` cannot
inject itself into the scope list.
"""
stmt = (
select(models.Peer)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.configuration.contains({"kind": SCOPE_KIND}))
.where(scope_peer_clause())
)
if reverse:
return stmt.order_by(models.Peer.created_at.desc(), models.Peer.id.desc())
@ -197,7 +213,7 @@ async def get_scope(
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == scope_peer_name(scope_name))
)
if peer is None or not is_scope_peer_configuration(peer.configuration):
if peer is None or not is_scope_peer(peer.name, peer.internal_metadata):
raise ResourceNotFoundException(
f"Scope {scope_name} not found in workspace {workspace_name}"
)

View File

@ -264,7 +264,7 @@ async def get_or_create_session(
db,
workspace_name=workspace_name,
peers=[
schemas.PeerCreate(name=peer_name) for peer_name in session.peer_names
schemas.PeerSpec(name=peer_name) for peer_name in session.peer_names
],
)
await _get_or_add_peers_to_session(
@ -990,7 +990,7 @@ async def set_peers_for_session(
peers_result = await get_or_create_peers(
db,
workspace_name=workspace_name,
peers=[schemas.PeerCreate(name=peer_name) for peer_name in peer_names],
peers=[schemas.PeerSpec(name=peer_name) for peer_name in peer_names],
)
# Add new peers to session
@ -1203,7 +1203,7 @@ async def set_peer_config(
"""
# First, get the session and peer to ensure they exist
await get_session(db, session_name, workspace_name)
await get_peer(db, workspace_name, schemas.PeerCreate(name=peer_name))
await get_peer(db, workspace_name, peer_name)
# Check if a SessionPeer entry already exists
stmt = (

View File

@ -10,7 +10,7 @@ from collections.abc import AsyncIterator
from pydantic import BaseModel
from src import crud, schemas
from src import crud
from src.config import ReasoningLevel
from src.dependencies import tracked_db
from src.dialectic.core import DialecticAgent
@ -48,9 +48,9 @@ async def agentic_chat(
"""
# Short-lived DB session for validation + config
async with tracked_db("dialectic.preflight", read_only=True) as db:
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer))
await crud.get_peer(db, workspace_name, observer)
if observer != observed:
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed))
await crud.get_peer(db, workspace_name, observed)
session = None
if session_name:
@ -120,9 +120,9 @@ async def agentic_chat_stream(
"""
# Short-lived DB session for validation + config
async with tracked_db("dialectic.preflight", read_only=True) as db:
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observer))
await crud.get_peer(db, workspace_name, observer)
if observer != observed:
await crud.get_peer(db, workspace_name, schemas.PeerCreate(name=observed))
await crud.get_peer(db, workspace_name, observed)
session = None
if session_name:

View File

@ -20,7 +20,7 @@ from typing import Any, cast
from nanoid import generate as generate_nanoid
from src import crud, schemas
from src import crud
from src.config import ConfiguredModelSettings, settings
from src.dependencies import tracked_db
from src.exceptions import ValidationException
@ -266,13 +266,9 @@ If you update it, send the full deduplicated list and remove stale entries.
try:
# Short-lived DB session for preflight operations
async with tracked_db("dream.specialist.preflight") as db:
await crud.get_peer(
db, workspace_name, schemas.PeerCreate(name=observer)
)
await crud.get_peer(db, workspace_name, observer)
if observer != observed:
await crud.get_peer(
db, workspace_name, schemas.PeerCreate(name=observed)
)
await crud.get_peer(db, workspace_name, observed)
# Determine if peer card tools should be included. Specialists that
# cannot write to the peer card (e.g., induction) skip the fetch and

View File

@ -136,7 +136,20 @@ async def update_peer(
peer: schemas.PeerUpdate = Body(..., description="Updated peer parameters"),
db: AsyncSession = db,
):
"""Update a Peer's metadata and/or configuration."""
"""Update a Peer's metadata and/or configuration.
Reserved-namespace names are refused outright. Name-based rather than
flag-based on purpose: every scope peer is named by ``scope_peer_name``, so
the prefix covers real scopes *and* stops ``crud.update_peer``'s get-or-create
from minting a new unflagged peer inside the reserved namespace which a
flag-based check would wave through. It also needs no DB round-trip. The
trade-off is that a legacy peer occupying the namespace can't be updated via
this route; it couldn't be before either, and `configuration` is replaced
wholesale here, so the generic route must not touch the facade's namespace.
"""
validate_no_scope_peer_names(
[peer_id], action="Use the scopes routes to manage scopes."
)
updated_peer = await crud.update_peer(
db, workspace_name=workspace_id, peer_name=peer_id, peer=peer
)
@ -204,11 +217,23 @@ async def chat(
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."
)
# query. Covers the path-level observer too: a scope `peer_id` no longer
# errors out downstream now that crud.get_peer takes a plain name, and a
# scope peer as the path-level observer is a Phase 2b concern.
scope_candidates = [
n for n in (peer_id, options.target) if n is not None and is_scope_peer_name(n)
]
if scope_candidates:
async with tracked_db("peers.chat.scope_check", read_only=True) as s_db:
await crud.reject_scope_peers(
s_db,
workspace_id,
scope_candidates,
action=(
"No representation is formed of a scope, so a scope cannot "
"be a chat observer or target."
),
)
# 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;
@ -254,7 +279,7 @@ async def chat(
peers_result = await crud.get_or_create_peers(
peer_db,
workspace_name=workspace_id,
peers=[schemas.PeerCreate(name=peer_id)],
peers=[schemas.PeerSpec(name=peer_id)],
)
await peer_db.commit()
await peers_result.post_commit()
@ -339,10 +364,23 @@ async def get_representation(
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."
)
# Covers the path-level observer as well as the target.
scope_candidates = [
n for n in (peer_id, options.target) if n is not None and is_scope_peer_name(n)
]
if scope_candidates:
async with tracked_db(
"peers.representation.scope_check", read_only=True
) as s_db:
await crud.reject_scope_peers(
s_db,
workspace_id,
scope_candidates,
action=(
"No representation is formed of a scope, so a scope cannot "
"be a representation observer or target."
),
)
# Parse the session allowlist from filters (422 on unsupported keys/shapes,
# and on a session_id the allowlist doesn't cover).

View File

@ -1,7 +1,7 @@
"""FastAPI routes for scope resources.
A scope is a named grouping of sessions that provides a visibility boundary
within a peer. Internally a scope is a peer named ``scope__<name>`` that
within a peer. Internally a scope is a peer named ``scope.<name>`` that
observes its member sessions and never speaks; these routes are the facade
that keeps the observer/observed mechanics hidden.

View File

@ -24,7 +24,6 @@ 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
@ -322,8 +321,8 @@ async def get_or_create_session(
# 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
await crud.reject_scope_peers(
db, workspace_id, session.peer_names.keys(), action=_SCOPES_ROUTE_GUIDANCE
)
# Handle session creation with proper error handling
@ -461,7 +460,9 @@ async def add_peers_to_session(
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)
await crud.reject_scope_peers(
db, workspace_id, peers.keys(), action=_SCOPES_ROUTE_GUIDANCE
)
try:
result = await crud.get_or_create_session(
db,
@ -500,7 +501,9 @@ async def set_session_peers(
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)
await crud.reject_scope_peers(
db, workspace_id, peers.keys(), action=_SCOPES_ROUTE_GUIDANCE
)
try:
await crud.set_peers_for_session(
db,
@ -540,7 +543,9 @@ async def remove_peers_from_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)
await crud.reject_scope_peers(
db, workspace_id, peers, action=_SCOPES_ROUTE_GUIDANCE
)
try:
await crud.remove_peers_from_session(
db,

View File

@ -31,6 +31,7 @@ from src.schemas.api import (
PeerCreate,
PeerGet,
PeerRepresentationGet,
PeerSpec,
PeerUpdate,
QueueStatus,
RepresentationResponse,
@ -129,6 +130,7 @@ __all__ = [
"PeerContext",
"PeerCreate",
"PeerGet",
"PeerSpec",
"PeerRepresentationGet",
"PeerUpdate",
"QueueStatus",

View File

@ -103,13 +103,17 @@ def _validate_scope_name(name: str) -> str:
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}")
# Checked before the charset pattern: the reserved prefix is itself outside
# RESOURCE_NAME_PATTERN, so the pattern would otherwise reject a
# double-prefixed name first and report the charset instead of the real
# mistake.
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)"
)
if not re.fullmatch(RESOURCE_NAME_PATTERN, name):
raise ValueError(f"Scope name must match pattern {RESOURCE_NAME_PATTERN}")
return name
@ -166,13 +170,27 @@ class PeerBase(BaseModel):
pass
class PeerCreate(PeerBase):
class PeerSpec(PeerBase):
"""Peer identity plus optional updates, for callers that already have a name.
``PeerCreate`` narrows ``name`` with ``pattern=RESOURCE_NAME_PATTERN`` because it
validates a *new, user-supplied* peer id at the API boundary. crud paths reach
``get_or_create_peers`` with names that already exist a path param, a message
author, an existing row including pre-``d429de0e5338`` legacy names containing
'.' and every ``scope.``-prefixed peer name. Re-validating those turns a lookup
into a raw pydantic ValidationError, i.e. an HTTP 500.
"""
name: Annotated[str, Field(min_length=1, max_length=512)]
metadata: _SanitizedMetadata | None = None
configuration: dict[str, Any] | None = None
class PeerCreate(PeerSpec):
name: Annotated[
str,
Field(alias="id", min_length=1, max_length=512, pattern=RESOURCE_NAME_PATTERN),
]
metadata: _SanitizedMetadata | None = None
configuration: dict[str, Any] | None = None
model_config = ConfigDict(populate_by_name=True) # pyright: ignore

View File

@ -2,15 +2,18 @@
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.
``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.
This module is the single source of truth for the reserved name prefix and the
``kind`` flag. Being a scope requires **both**: the reserved name prefix (the
namespace) and ``{"kind": "scope"}`` in the peer's ``internal_metadata`` JSONB
(the authoritative marker). Neither half is forgeable the prefix sits outside
``RESOURCE_NAME_PATTERN``, and ``internal_metadata`` appears in no API schema
so requiring both means a peer that merely occupies the namespace, or merely
carries a look-alike ``configuration``, is not a scope.
"""
from collections.abc import Iterable
@ -19,7 +22,14 @@ 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__"
#
# The '.' is load-bearing: it is outside RESOURCE_NAME_PATTERN
# (^[a-zA-Z0-9_-]+$), the charset every peer name created through the API must
# match. No peer created through the validated API can therefore occupy this
# namespace. (Peers carried over by the users->peers rename in
# d429de0e5338 predate that pattern and were never charset-validated, so the
# legacy-collision path in crud/scope.py stays as a backstop.)
SCOPE_PEER_PREFIX = "scope."
# Value of the `kind` configuration flag carried by scope peers.
SCOPE_KIND = "scope"
@ -46,9 +56,18 @@ def scope_name_from_peer(peer_name: str) -> str:
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 is_scope_peer(name: str, internal_metadata: dict[str, Any] | None) -> bool:
"""Authoritative scope test: reserved name AND the internal kind flag.
Takes ``(name, internal_metadata)`` rather than a ``Peer`` so it is callable
from an ORM instance, the cached plain dict built by ``crud.peer._fetch_peer``,
or a raw row.
"""
return (
is_scope_peer_name(name)
and bool(internal_metadata)
and internal_metadata.get("kind") == SCOPE_KIND
)
def validate_no_scope_peer_names(names: Iterable[str], *, action: str) -> None:

View File

@ -28,7 +28,7 @@ from sqlalchemy.ext.asyncio import (
from src import crud, models, schemas
from src.crud.peer import peer_cache_key
from src.crud.scope import SCOPE_PEER_CONFIGURATION
from src.crud.scope import SCOPE_PEER_CONFIGURATION, SCOPE_PEER_INTERNAL_METADATA
from src.utils.scopes import scope_peer_name
@ -146,6 +146,7 @@ async def test_scope_retry_still_invalidates_mutated_scope(
models.Peer(
name=scope_peer_name(racing_scope),
workspace_name=test_workspace.name,
internal_metadata=dict(SCOPE_PEER_INTERNAL_METADATA),
configuration=dict(SCOPE_PEER_CONFIGURATION),
)
],

View File

@ -1,10 +1,12 @@
"""Tests for the scopes facade: scope-kind peers, guardrails, and CRUD routes.
A scope is a named grouping of sessions, implemented as a peer named
``scope__<name>`` with configuration ``{"kind": "scope", "observe_me": false}``
that observes its member sessions and never speaks. See src/utils/scopes.py.
``scope.<name>`` carrying ``{"kind": "scope"}`` in ``internal_metadata`` and
``{"observe_me": false}`` in ``configuration``, that observes its member sessions
and never speaks. See src/utils/scopes.py.
"""
import re
from typing import Any
import pytest
@ -13,13 +15,15 @@ from nanoid import generate as generate_nanoid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src import crud, models
from src.config import settings
from src.deriver.enqueue import enqueue
from src.models import Peer, QueueItem, Workspace
from src.schemas.api import RESOURCE_NAME_PATTERN
from src.security import JWTParams, create_jwt
from src.utils.scopes import (
SCOPE_PEER_PREFIX,
is_scope_peer,
is_scope_peer_name,
scope_name_from_peer,
scope_peer_name,
@ -68,10 +72,13 @@ async def _get_session_peer(
def test_scope_namespace_helpers():
assert scope_peer_name("therapy") == "scope__therapy"
assert is_scope_peer_name("scope__therapy")
assert scope_peer_name("therapy") == "scope.therapy"
assert is_scope_peer_name("scope.therapy")
assert not is_scope_peer_name("therapy")
assert scope_name_from_peer("scope__therapy") == "therapy"
assert scope_name_from_peer("scope.therapy") == "therapy"
# The prefix must stay outside the peer-name charset, or an existing peer
# could occupy the scope namespace.
assert not re.fullmatch(RESOURCE_NAME_PATTERN, SCOPE_PEER_PREFIX)
async def test_create_scope_creates_flagged_peer(
@ -99,7 +106,10 @@ async def test_create_scope_creates_flagged_peer(
.where(models.Peer.name == scope_peer_name(scope_name))
)
assert peer is not None
assert peer.configuration == {"kind": "scope", "observe_me": False}
# The kind flag lives in internal_metadata (not user-writable); only
# observe_me is in the user-visible configuration.
assert peer.internal_metadata == {"kind": "scope"}
assert peer.configuration == {"observe_me": False}
def test_create_scope_idempotent(
@ -126,7 +136,7 @@ def test_create_scope_rejects_invalid_names(
assert response.status_code == 422
# Scope names are unprefixed: double-prefixing is rejected
response = _create_scope(client, test_workspace.name, "scope__therapy")
response = _create_scope(client, test_workspace.name, "scope.therapy")
assert response.status_code == 422
@ -221,7 +231,12 @@ def test_scopes_routes_require_workspace_level_key(
def test_peer_create_rejects_reserved_prefix(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""User-created peers may not use the reserved scope prefix."""
"""User-created peers may not use the reserved scope prefix.
The prefix sits outside RESOURCE_NAME_PATTERN, so PeerCreate's own charset
validation rejects it at the schema boundary one layer earlier than the
route's validate_no_scope_peer_names guard. Either way the caller gets 422.
"""
test_workspace, _ = sample_data
response = client.post(
@ -229,7 +244,7 @@ def test_peer_create_rejects_reserved_prefix(
json={"name": f"{SCOPE_PEER_PREFIX}{generate_nanoid()}"},
)
assert response.status_code == 422
assert SCOPE_PEER_PREFIX in response.json()["detail"]
assert RESOURCE_NAME_PATTERN in str(response.json()["detail"])
def test_peers_list_kind_filtering(
@ -456,7 +471,8 @@ async def test_session_create_with_scopes(
.where(models.Peer.name == scope_peer_name(scope_name))
)
assert peer is not None
assert peer.configuration == {"kind": "scope", "observe_me": False}
assert peer.internal_metadata == {"kind": "scope"}
assert peer.configuration == {"observe_me": False}
session_peer = await _get_session_peer(
db_session, test_workspace.name, session_name, scope_peer_name(scope_name)
@ -480,7 +496,7 @@ def test_session_create_rejects_prefixed_scope_names(
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={"id": str(generate_nanoid()), "scopes": ["scope__x"]},
json={"id": str(generate_nanoid()), "scopes": ["scope.x"]},
)
assert response.status_code == 422
@ -595,3 +611,206 @@ async def test_scope_peer_observes_ingested_messages(
assert observers is not None
assert test_peer.name in observers # self-observation
assert scope_peer_name(scope_name) in observers # the scope observes
# ---------------------------------------------------------------------------
# Dotted / legacy peer names must not 500 (regression for the PeerCreate-as-DTO
# chokepoint). Names were length-only validated before migration
# d429de0e5338, so pre-existing peers can contain '.' — and re-validating them
# against PeerCreate's charset pattern raised a raw pydantic error, i.e. a 500.
# ---------------------------------------------------------------------------
async def test_legacy_dotted_peer_name_is_fully_usable(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A pre-existing dotted peer name must work end to end, not 500."""
test_workspace, _ = sample_data
legacy_name = f"alice.smith.{generate_nanoid()}"
db_session.add(models.Peer(workspace_name=test_workspace.name, name=legacy_name))
await db_session.commit()
session_name = _create_session(client, test_workspace.name)
# Can author messages
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": legacy_name, "content": "hello"}]},
)
assert response.status_code in [200, 201], response.text
# Can be added to a session through the generic route
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/peers",
json={legacy_name: {}},
)
assert response.status_code == 200, response.text
# Can be updated through the generic peer route
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{legacy_name}",
json={"metadata": {"k": "v"}},
)
assert response.status_code == 200, response.text
async def test_legacy_prefixed_peer_without_flag_is_not_a_scope(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A peer merely occupying the reserved namespace keeps working.
Only the name half of the invariant is present, so it is not a scope: it can
still author messages and shows up in the default peers list, while the
scopes facade refuses to treat it as a scope.
"""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
squatter = scope_peer_name(scope_name)
db_session.add(models.Peer(workspace_name=test_workspace.name, name=squatter))
await db_session.commit()
session_name = _create_session(client, test_workspace.name)
# Not a scope, so the message-author guard must not fire
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": squatter, "content": "hello"}]},
)
assert response.status_code in [200, 201], response.text
# Visible in the default (non-scope) peers list
response = client.post(f"/v3/workspaces/{test_workspace.name}/peers/list")
assert squatter in [p["id"] for p in response.json()["items"]]
# But the facade does not recognise it
response = client.get(f"/v3/workspaces/{test_workspace.name}/scopes/{scope_name}")
assert response.status_code == 404
response = client.post(f"/v3/workspaces/{test_workspace.name}/scopes/list")
assert scope_name not in [s["id"] for s in response.json()["items"]]
async def test_forged_configuration_kind_does_not_make_a_scope(
client: TestClient,
sample_data: tuple[Workspace, Peer],
):
"""`configuration` is user-writable, so it must not be load-bearing.
A peer that forges `{"kind": "scope"}` in configuration has neither the
reserved name nor the internal flag, so it stays an ordinary peer.
"""
test_workspace, _ = sample_data
peer_name = str(generate_nanoid())
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{peer_name}",
json={"configuration": {"kind": "scope"}},
)
assert response.status_code == 200, response.text
# Still an ordinary peer: present in the default list, absent from scopes
response = client.post(f"/v3/workspaces/{test_workspace.name}/peers/list")
assert peer_name in [p["id"] for p in response.json()["items"]]
response = client.post(f"/v3/workspaces/{test_workspace.name}/scopes/list")
assert peer_name not in [s["id"] for s in response.json()["items"]]
# And can still author messages
session_name = _create_session(client, test_workspace.name)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/sessions/{session_name}/messages",
json={"messages": [{"peer_id": peer_name, "content": "hello"}]},
)
assert response.status_code in [200, 201], response.text
def test_update_peer_rejects_reserved_prefix(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""PUT on a reserved-namespace name is a clean 422, never a 500."""
test_workspace, _ = sample_data
response = client.put(
f"/v3/workspaces/{test_workspace.name}/peers/{scope_peer_name('therapy')}",
json={"metadata": {"k": "v"}},
)
assert response.status_code == 422
assert SCOPE_PEER_PREFIX in response.json()["detail"]
async def test_scope_peer_cannot_be_chat_or_representation_observer(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""The path-level peer_id is guarded, not just `target`."""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
backing = scope_peer_name(scope_name)
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/{backing}/chat",
json={"query": "what do you know?"},
)
assert response.status_code == 422, response.text
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/{backing}/representation",
json={},
)
assert response.status_code == 422, response.text
def test_internal_metadata_never_in_peer_response(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""The scope flag must not leak into any peer response body."""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
response = client.post(
f"/v3/workspaces/{test_workspace.name}/peers/list", json={"kind": "all"}
)
assert response.status_code == 200
for peer in response.json()["items"]:
assert "internal_metadata" not in peer
assert "kind" not in peer.get("configuration", {})
async def test_crud_get_peer_resolves_scope_and_dotted_names(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""crud.get_peer must accept names outside RESOURCE_NAME_PATTERN.
This is the Dreamer's preflight path: DreamScheduler passes
``collection.observer`` straight through, and scope peers have
``observe_others=true``, so ``(scope.x, peer)`` collections exist and get
dreamt. While get_peer took a PeerCreate, every such dream died at preflight
on a raw pydantic ValidationError.
"""
test_workspace, _ = sample_data
scope_name = str(generate_nanoid())
assert _create_scope(client, test_workspace.name, scope_name).status_code == 201
resolved = await crud.get_peer(
db_session, test_workspace.name, scope_peer_name(scope_name)
)
assert resolved.name == scope_peer_name(scope_name)
assert is_scope_peer(resolved.name, resolved.internal_metadata)
dotted = f"legacy.dotted.{generate_nanoid()}"
db_session.add(models.Peer(workspace_name=test_workspace.name, name=dotted))
await db_session.commit()
resolved = await crud.get_peer(db_session, test_workspace.name, dotted)
assert resolved.name == dotted
# Has neither half of the invariant
assert not is_scope_peer(resolved.name, resolved.internal_metadata)