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:
parent
679aa25388
commit
48047a6a99
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue