Scopes Phase 2b: `scope` option on chat, representation, context, and search (#897)

* fix: apply session scoping to all working-representation query paths

session_name was only applied to the recent-documents query in
RepresentationManager; the semantic and most-derived paths ignored it,
so limit_to_session leaked cross-session conclusions into perspectives.

- Thread a session allowlist (session_names) uniformly through all
  three query paths; pushed down to pgvector and external vector stores
- Accept a list so the upcoming session-allowlist API reuses this path
- Fail closed on an empty allowlist (downstream stores drop empty IN
  clauses, which would silently widen scope)

Fixes DEV-1994

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: bare-list membership sugar in the filter DSL

{"session_id": ["s1", "s2"]} is now shorthand for
{"session_id": {"in": [...]}} on regular columns, generically
(peer_id, etc.). JSONB metadata columns are excluded — a bare list
there keeps JSONB containment semantics, unchanged.

Previously a bare list on a regular column compiled to a type-mismatched
equality that matched nothing, so this is strictly additive.

Also translates the same shape in the turbopuffer/lancedb filter
builders, and fixes lancedb dropping empty IN clauses (fail-open) —
an empty membership list now emits an always-false condition.

Groundwork for DEV-1995 (session allowlist via the existing filters
DSL, no new API params)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: session allowlist on dialectic and representation via filters

Adds a constrained 'filters' body to peer.chat and /representation —
the same DSL search and conclusions already accept, supporting only the
session_id key (a session id, a bare list, or {"in": [...]}).
Unsupported keys and shapes are rejected with 422, never silently
ignored. Composes with session_id (must be included in the allowlist
when both are given). Capped at 1,000 sessions per request.

Enforcement is uniform at every recall chokepoint, fail-closed:
- dialectic prefetch + search_memory: conclusion recall restricted to
  the allowlist; dream docs (session_name IS NULL) excluded
- message tools (search/grep/date-range/temporal/context/history):
  strict intersection of allowlist and observer session membership
- get_reasoning_chain: unavailable under an allowlist (chains traverse
  provenance across sessions and cannot be scoped without leaking)
- empty allowlist short-circuits to empty results everywhere

Auth: workspace keys pass the allowlist as-given; peer-scoped JWTs must
be a member of every allowlisted session (403 otherwise), mirroring the
existing single-session check.

Fixes DEV-1995

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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>

* feat: add scopes CRUD routes and session-create scopes wiring

New /v3/workspaces/{workspace_id}/scopes facade (workspace-level auth;
peer- and session-scoped keys are rejected):

  POST   ""                                   create-or-get (201/200)
  POST   /list                                 paginated scope list
  GET    /{scope_id}                           single scope
  POST   /{scope_id}/sessions                  add memberships
  DELETE /{scope_id}/sessions/{session_id}     remove membership
  GET    /{scope_id}/sessions                  list member session ids

- crud/scope.py: get_or_create_scopes stamps the backing peer with
  {"kind": "scope", "observe_me": false} and refuses to adopt a
  legacy peer occupying the reserved name without the flag (409)
- memberships are session_peers rows with observe_others=true /
  observe_me=false — identical to a hand-built observer peer
- SessionCreate.scopes: create-or-get each scope peer and add the
  membership at session creation (the no-backfill common path)
- crud/session.py: public upsert_session_peers wrapper so the facade
  bypasses the route-level guardrails without reaching into privates

Backfill of pre-existing documents and reconciliation on removal land in
DEV-1999; membership only affects messages ingested after the change.

Part of DEV-1997 (Scopes RFC DEV-1970).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: cover scopes facade, guardrails, and observer semantics

- create-or-get idempotency, list/get, name validation, legacy-collision
  rejection (409), auth scoping (workspace key ok, peer/session keys 401)
- reserved prefix rejected on peer create; peers.list kind filtering
- scope peers rejected as message authors, chat/representation targets,
  and on the generic session-peer routes
- membership add/list/remove with observe_others=true / observe_me=false
  row shape asserted via DB, and facade-less equivalence with a
  hand-built observer peer
- end-to-end litmus: after adding a session to a scope, the deriver
  enqueue fan-out includes the scope peer as an observer
- session creation with scopes: [a, b] creates both memberships

Part of DEV-1997 (Scopes RFC DEV-1970).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scopes): scope resolution helper and read-route schemas

Add resolve_scope_peers (crud/scope.py) mapping unprefixed scope names to
their backing scope peers, 404 when missing and 422 when a non-scope peer
squats the reserved name. Add the `scope` option to DialecticOptions and
PeerRepresentationGet, and a WorkspaceMessageSearchOptions with `scope`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scopes): wire the `scope` read option into the routes

Chat and representation: a single scope swaps the observer to the scope
peer (recall confined to the scoped collection and the scope's sessions by
existing observer semantics); a scope list resolves to the union of member
sessions and rides the DEV-1995 dynamic allowlist arm with the path peer as
observer. `scope` is mutually exclusive with `filters`/`session_id` (422)
and requires a workspace/admin key (403 for peer-scoped JWTs).

Session context: `scope` swaps the perspective source for both the working
representation and the peer-card fetch. Workspace search: `scope` injects
the scope's session set into the message-search filter (empty scope -> no
results). Close the carry-over guardrail gap: scope peers are rejected as
peer_target/peer_perspective in session context and as the peer/target in
GET /peers/{id}/context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(scopes): cover the `scope` read option end-to-end

Validation (404/422/403), single-scope observer swap vs. union allowlist,
scoped representation and session-context (scoped collection + scoped card),
workspace search restricted to a scope's sessions, and guardrail closure for
scope peers on the peer/session context surfaces. Patch the workspaces
tracked_db import site so scope resolution reads the per-test database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(crud): preserve cache invalidation across get_or_create retry

`get_or_create_peers` and `get_or_create_scopes` mutate existing rows, then
insert new ones inside `db.begin_nested()`. When a concurrent writer creates
one of those rows first, the insert raises IntegrityError and the function
retries.

`begin_nested()` autoflushes the pending UPDATEs *before* opening the
savepoint, so the rollback neither undoes them nor expires the now-clean ORM
state. The retry then compared already-updated values, found no change, and
dropped those peers from `changed_peers` — skipping the cache purge while the
row change committed anyway, leaving entries stale until the 300s TTL.

Carry the mutated names into the retry via `_pending_invalidation` so the
purge cannot be lost.

The scopes facade mirrors `get_or_create_peers`, so both copies carried this.
The peer path is pre-existing and runs on every message ingest.

Also add the missing /v3 prefix to `_SCOPES_ROUTE_GUIDANCE`, which pointed
callers at a 404.

Adds tests/crud/test_get_or_create_retry_invalidation.py, which drives a real
racing session and fails without this change.

* 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>

* fix(scopes): validate peer names on create, close namespace squatting and upsert race

Addresses three review findings against 48047a6a.

1. `PeerSpec` let API callers create invalid and reserved-prefix peers.
   Widening `get_or_create_peers` to accept a pattern-free schema fixed the
   lookup 500s but also removed validation from the *insert* path, and
   request-controlled names reach it via message authors, session peer maps, and
   the chat observer path — none of which carry a charset pattern of their own.
   Confirmed: `POST /sessions/{id}/messages` with `peer_id: "scope.x"` returned
   201 and minted an unflagged squatter, after which `POST /scopes {id: x}` was
   permanently 409-blocked — namespace denial of service by any caller able to
   post a message. `peer_id: "not a valid name!@#"` was likewise created.

   Fixed by validating only names about to be INSERTed
   (`_validate_new_peer_names`), so already-existing names — legacy dotted
   names, scope peers — still resolve without a spurious 422. That keeps the
   Dreamer fix intact, since it reads through `get_peer`.

2. Existing reserved-prefix squatters could not be updated. The name-based guard
   on `PUT /peers/{peer_id}` refused every `scope.` name, contradicting the
   invariant that an unflagged squatter stays a normal peer. Now flag-based, so
   behavior is three-way: a real scope is refused, an existing unflagged peer
   updates, and a missing reserved-prefix name is refused by (1) rather than
   minted.

3. Scope checks raced with get-or-create and the membership upsert. The
   route-level guards run before peers are resolved, so a scope created
   concurrently in that window would be attached by the generic path with a
   default `SessionPeerConfig()`, clobbering its observer membership config.
   Adds `_reject_resolved_scope_peers`, which runs on the resolved rows in the
   same transaction as the upsert — no window, no extra query. The early checks
   stay for better error messages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scopes): guard scope membership config, move checks to the mutation point

Addresses a second review pass against 10655792.

1. Scope membership configuration was directly user-mutable.
   `PUT /sessions/{id}/peers/{peer_id}/config` had no scope guard at all, and
   `crud.set_peer_config` resolved the peer only to discard the row. Confirmed:
   posting `{"observe_others": false, "observe_me": true}` for a scope returned
   204 and persisted, which silently stops all fan-out into the scope and makes
   Honcho form a representation *of* a scope — neither of which is reachable
   through the facade. Deterministic, no race required. Now checked on the row
   `get_peer` already returns, so it costs nothing and cannot race.

2. Empty and over-long names were still 500s. Removing the charset pattern from
   `PeerSpec` fixed one trap but left its length bounds, and request-bound peer
   names carry no length limits of their own — so `peer_id: ""` or a 513-char
   name reached `PeerSpec(...)` and raised a raw pydantic ValidationError that
   the catch-all turned into a 500. `PeerSpec` now carries no constraints at all
   (matching its documented purpose) and every rule for a new name lives in
   `_validate_new_peer_names` on the insert path.

3. Resolved-row protection generalized. The previous pass applied it only to
   membership upserts, leaving check-then-use windows elsewhere: peer update
   could have a concurrently-created scope's configuration replaced wholesale
   (create-path validation does not fire for a peer that now exists), the chat
   observer get-or-create could resolve a fresh scope as its observer, and the
   generic session-peer removal could silently detach a scope from its sessions.
   Each now inspects the resolved peer immediately before acting; the redundant
   name-level guard on the update route is dropped in favor of the race-free one.

`remove_peers_from_session` grows an internal `_allow_scope_peers` flag because
the scopes facade ends membership through that same path and must not be blocked
by its own guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(scopes): enumerate every peer-touching route against a scope policy

Three review passes each found the same class of defect: a route nobody had
checked, rather than logic that was subtly wrong. One of them — `PUT
/sessions/{id}/peers/{id}/config`, which let any caller set a scope to
`observe_others=false` and silently stop all fan-out into it — predated this work
entirely, because the guardrail set was assembled guardrail-by-guardrail instead
of derived from the route list. Sampling review cannot close that kind of gap;
enumeration can.

Derives every route through which a peer name can reach the system and requires
each to be classified as GUARDED or EXEMPT-with-a-reason, so a newly added
peer-touching route fails the suite until someone classifies it. Detection is the
union of path shape and a walk of the dependant tree (including sub-dependency
`Form(...)` params and nested request-body models), because neither signal alone
suffices: parameter names miss `POST /sessions/{id}/peers`, whose peer names are
dict keys, and path shape misses `messages/upload`, whose `peer_id` arrives as a
form field behind a parser dependency.

Both invariants are then asserted behaviorally, by calling the routes rather than
inspecting annotations — the guards deliberately live in crud, which is what makes
`messages/upload` guarded for free via `crud.create_messages`:

- a real scope is refused on all 11 guarded routes, and the rejection must name
  the scope, so an unrelated 422 (a malformed body) cannot pass the assertion;
- an *unflagged* peer merely occupying the reserved namespace is unaffected. That
  half regressed once already when `update_peer` used a name-based check.

Mutation-tested all three failure modes: disabling the `set_peer_config` guard
fails the guarded test naming that route; regressing `update_peer` to name-based
fails the squatter test; adding an unclassified peer route fails the enumeration.

Covers the HTTP surface only. Peer names also reach the system through the
deriver, dreamer, and queue, which have no route table to enumerate — noted in
the module docstring rather than implied to be covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scopes): refuse a scope in every observed position; enumerate per position

Addresses a fourth review pass against 62681e4d. The headline finding is that the
previous commit's enumeration test had the wrong *model*, not a missing entry.

1. Manual conclusions could create knowledge about a scope. `POST /conclusions`
   validated only that observer_id and observed_id exist, so a scope as
   `observed_id` persisted a conclusion about a peer carrying observe_me=false and
   created an (observer, scope) collection for it. Confirmed: 201, and it read
   back. `POST /schedule_dream` had the same hole via `observed`.

   The fix is positional, because the invariant is:

       A scope may be an OBSERVER. A scope may never be OBSERVED.

   A scope as `observer_id` is how scoped conclusions are stored and must keep
   working (verified still 201); as `observed_id` it is now refused. Same split
   applied to schedule_dream `observed`, the peer-card `target` (which also covers
   a scope's self-card, since target-omitted collapses observed to peer_id), and
   session-context `peer_target`.

2. Chat target and both representation roles kept check-to-use races. Only the
   chat path-level observer was re-checked on its resolved row; the target was
   checked by name and then resolved without inspecting scope identity. Both are
   now checked at the dialectic preflight, where observer and observed are already
   resolved — an absent name has already failed by then, and an existing squatter
   cannot retroactively become a scope.

3. Generic membership removal was still racy. The adjacent SELECT narrowed the
   window but could not close it under READ COMMITTED. The UPDATE now carries its
   own correlated NOT EXISTS against scope_peer_clause(), so Postgres evaluates
   the exclusion as part of the statement and a scope committed after the advisory
   check still cannot be detached.

4. New-name validation ran after the name reached Postgres. A NUL byte passed the
   request schemas and PeerSpec, then raised psycopg.DataError inside the lookup —
   a 500. Values that cannot correspond to a stored row by construction (NUL
   bytes, over-length names) are now refused before the query.
   (Over-length names already returned 422; only the wasted query was real there.)

The enumeration test is rekeyed from (method, path) to (method, path, position).
A binary per-route verdict cannot express finding 1 at all: `POST /conclusions` is
one route with two positions and opposite verdicts. Detection widens to observer /
observed / target / peer_target / peer_perspective, which surfaced four routes the
previous version never saw — conclusions, schedule_dream, queue/status, and
session context.

Also registers `src.routers.workspaces.tracked_db` in the conftest patch list; the
new guard there would otherwise have run against the real configured database
instead of the per-test one.

Mutation-tested: disabling the conclusions observed-guard fails the positional
test naming that position; adding an unclassified `observed_id` param fails
enumeration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scopes): refuse future scopes in observed positions, preserve scope membership

Addresses a fifth review pass against 14136e5b. All six findings reproduced
locally before fixing.

1. High — generic peer replacement removed scope memberships.
   `set_peers_for_session` soft-deleted every active SessionPeer row, and the
   request-level guard only inspected names *present* in the replacement map. A
   caller detached a scope by simply omitting it, never naming it — so no
   request-level guard could ever see it. Reproduced: scope sessions went
   ['<id>'] -> [] on a 200. The exclusion now lives in the UPDATE itself
   (correlated NOT EXISTS against scope_peer_clause), so replacement means
   "replace ordinary peers" regardless of request contents or concurrent creation.

2. High — peer cards could be pre-seeded for future scopes.
   `set_peer_card` resolves only the observer and writes a JSONB key derived from
   an unchecked observed name, and the route guard rejected only *existing*
   flagged scopes. Reproduced: PUT card with target=scope.<missing> returned 200,
   creating that scope then returned 201, and the card described the real scope.

3. High — dreams could be queued for future scopes.
   The route checked `observed` in a read-only session that closed before
   `enqueue_dream`, and a missing reserved name passes any is-it-a-scope check.
   Reproduced: 204 with observed=scope.<missing>.

   2 and 3 share a root cause, so they share a fix: a new `reject_scope_observed`
   that is stricter than `reject_scope_peers` in exactly one case — a *missing*
   reserved name is refused, because nothing on these paths creates the peer, so
   nothing else would ever catch it. Existing unflagged squatters still pass.
   Both guards moved to the mutation point: card validation into
   `crud.set_peer_card` (same transaction as the JSONB write, so Dreamer and
   agent-tool callers are covered), dream validation into `enqueue_dream` (same
   transaction as the queue insert). The redundant route-level checks are dropped
   rather than left as weaker duplicates.

4. Medium — prefixed NUL names still reached PostgreSQL.
   `reject_scope_peers` filtered for the reserved prefix and sent matches to a
   text comparison, so "scope.future\0name" raised psycopg.DataError — a 500.
   Both guards now share `_reserved_name_candidates`, which materializes the input
   once and rejects impossible values before any SQL. Materializing matters
   independently: the message-author path passes a generator, and validation
   iterates separately from the prefix filter, so a generator would be
   half-consumed. `_reject_impossible_peer_names` now takes a Collection so the
   type checker enforces that.

5. Medium — representation kept a check-to-use race.
   The previous commit claimed both representation roles were rechecked after
   resolution; that was wrong — only the dialectic preflight got that check, and
   the representation route never goes through it. It now opens one short
   read-only session *after* the embedding call, checks both positions, and passes
   that same session to `get_working_representation`, so no connection is held
   across external work and a scope committed later cannot have conclusions in the
   collection being read.

6. Low — policy coverage was not exhaustive. `sender_id` reaches CRUD as
   `observed` but was missing from the detected parameter set. ALLOW cases could
   also not carry builders, so the suite never proved the other half of the
   contract — that legitimate scope *observers* keep working, which a guard
   rejecting scopes everywhere would satisfy. Both fixed; observer positions on
   conclusions, dreams, cards, session context and queue status are now asserted
   behaviorally.

Deliberately not implemented: the scope-creation backstop scanning for
pre-existing card keys and queue items naming a future backing peer. Reasoning is
recorded in `get_or_create_scopes` — no new such state can be created now, any
pre-existing row is coincidental since `scope.` was never a meaningful namespace,
the consequence is inert, and detecting card keys means a full table scan per
scope creation.

Mutation-tested each new guard: removing the replacement exclusion fails both
membership-preservation tests; weakening either observed guard to existing-only
fails the pre-seeding tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scopes): exclude scope memberships from the session observer limit

Scope memberships carry observe_others=true, so every scope counted against
SESSION_OBSERVERS_LIMIT (default 10) — capping scopes-per-session at the limit
minus the session's real observers, and reporting the failure as
`400 Cannot create session <name> with 11 observers. ... Observers are peers
with 'observe_others' set to true.` on a membership call. Wrong on three counts:
the ceiling is undocumented and contradicts RFC §5.1 ("sessions belong to any
number of scopes"), the message describes session creation, and it leaks the
word "observer" through a facade whose entire job is hiding observers (RFC
goal 5). The limit exists to bound per-observer deriver fan-out for real peers;
a scope costs document rows, not LLM calls (RFC §5.2), so it does not belong in
that budget.

Excluded from both halves of the check in `_get_or_add_peers_to_session`: the
incoming names via a flag-based lookup, existing memberships via a correlated
NOT EXISTS on `scope_peer_clause()` — the same pattern the replacement and
removal paths already use, so the exclusion holds regardless of concurrent
scope creation. The early `count_observers_in_config(session.peer_names)` check
in `get_or_create_session` is left alone: `peer_names` cannot contain a scope,
and `scopes` is a separate field.

`reject_scope_peers` is split into a `scope_peer_names()` query helper plus a
two-line raiser so the observer count reuses the authoritative name-AND-flag
predicate instead of growing a third copy of it. Still costs nothing on the
common path — no reserved-prefix name in the input means no query at all.

Also caps `SessionCreate.scopes` at 100, matching `ScopeSessionsAdd.session_ids`.
This belongs in the same commit: the observer limit was the only thing bounding
that list, so removing it turns an unbounded `scopes` array into a peer row and
a membership row per element, committed — the single-request path to the
cardinality anti-pattern RFC §8 warns about. Partly answers OQ6: no per-session
cap, 100 per request.

Tests: a session joins SESSION_OBSERVERS_LIMIT + 2 scopes through both the
facade and session creation; real observers over the limit still 400, so the
carve-out cannot quietly disable the limit; 101 scopes is a 422.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scopes): skip semantic retrieval when the embedding precompute failed

Addresses three open review comments.

1. Major — the representation read could embed inside its DB session. The
   route's precompute is suppressed, and both
   `RepresentationManager.get_working_representation` and
   `crud.query_documents` fall back to embedding when a query arrives without
   one, so a failed precompute meant an external call inside the read session
   this branch opens for the scope re-check — the connection-holding rule the
   route's own comment claimed to satisfy. The innermost fallback also only
   catches ValueError, so a provider outage surfaced as a 500. The semantic
   query is now passed only when an embedding exists, degrading to
   derived+recent retrieval. (`crud.query_documents` embedding inside a caller's
   session predates this branch and is left alone.)

2. Minor — `test_resolved_scope_peer_rejected_at_membership_upsert` described a
   race it does not perform. It creates an already-flagged scope and calls crud
   directly; the unflagged → flagged transition is not simulated. Docstring now
   says what the test actually pins.

3. Minor — `test_empty_replacement_preserves_scope_membership` asserted only
   half its docstring. It passed if the empty PUT left every ordinary
   membership intact; now asserts the ordinary peer's left_at is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scopes): close auth and observed-position gaps, paginate membership

Review response for #884.

Security:
- gate `SessionCreate.scopes` behind a workspace-level key; the session-create
  route is self-authorizing, so a peer- or session-scoped token could mint scope
  peers and join sessions to scopes it had no access to via `POST /scopes`
- refuse a reserved-but-nonexistent name in two observed positions that used the
  permissive guard: chat `target` and session-context `peer_target`. Both let a
  caller act on `scope.X` before it existed, then create the scope

Facade:
- exclude scope peers from `GET /sessions/{id}/peers` and refuse the membership
  -config read for a real scope, matching its write side
- replace `GET /scopes/{id}/sessions` with `POST /scopes/{id}/sessions/list`
  returning `Page[Session]`; the add route now returns 204. Membership was
  unbounded on both, while every other list surface paginates
- rename `crud.get_scope` to `get_scope_or_raise`

Tests:
- add a missing-name axis to the route-policy table (`Case.refuse_missing`), which
  is what surfaced the two guard gaps above
- delete 14 hand-written tests the table now enumerates; 52 -> 39 functions in
  test_scopes.py with more cases covered
- tighten the squatter assertion from `!= 422` to `< 400`, which was passing on 5xx
- assert the FastAPI-internals traversal still derives positions, so a framework
  upgrade can't silently empty the suite

Docs:
- drop internal ticket and RFC references from the published OpenAPI descriptions
  and surrounding comments; state the behavior instead
- move implementation reasoning out of the `PUT /peers/{id}` docstring, which
  FastAPI publishes, into a comment

* test(scopes): assert exact statuses for permissive missing-name cases

Follow-up review pass on #884.

- add `Case.missing_status` so a permissive missing-name position asserts the
  status it should actually get (404, or 200 for the no-op removal) instead of
  `!= 422`, which also passed on a 5xx — the same hole already closed in the
  squatter assertion
- require it whenever `refuse_missing` is False, and require its absence when
  True, so the policy table can't drift from the assertion
- repoint a stale allow-reason at POST /scopes/{scope_id}/sessions/list; the GET
  it named was removed
- document the membership list's ordering under `reverse`

* fix: Address Coderabbit Comments

* fix: Address more coderabbit comments

* chore: remove ai artifacting

* fix: remove unused fakeredis client in lieu of in-memory taskless cache for test suite

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vineeth Voruganti 2026-08-12 18:34:55 -04:00 committed by GitHub
parent 57ae7ef4d8
commit be7ad9d919
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1585 additions and 139 deletions

View File

@ -53,7 +53,6 @@ dev = [
"pre-commit>=4.2.0",
"pytest-cov>=6.2.1",
"honcho-ai",
"fakeredis>=2.32.0",
"scipy>=1.15.3",
"boto3>=1.42.5",
"pytest-xdist>=3.8.0",

View File

@ -54,6 +54,7 @@ from .scope import (
get_scope_sessions,
get_scopes,
remove_session_from_scope,
resolve_scope_peers,
)
from .session import (
SessionDeletionResult,
@ -141,6 +142,7 @@ __all__ = [
"get_scope_sessions",
"get_scopes",
"remove_session_from_scope",
"resolve_scope_peers",
# Session
"SessionDeletionResult",
"get_sessions",

View File

@ -12,6 +12,7 @@ scope. Conclusions already derived are neither backfilled on add nor
reconciled on removal.
"""
from collections.abc import Sequence
from logging import getLogger
from sqlalchemy import Select, select
@ -20,7 +21,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import models, schemas
from src.cache.client import safe_cache_delete
from src.exceptions import ConflictException, ResourceNotFoundException
from src.exceptions import (
ConflictException,
ResourceNotFoundException,
ValidationException,
)
from src.utils.scopes import (
SCOPE_KIND,
is_scope_peer,
@ -232,6 +237,69 @@ async def get_scope_or_raise(
return peer
async def resolve_scope_peers(
db: AsyncSession,
workspace_name: str,
scope_names: Sequence[str],
) -> list[str]:
"""
Resolve unprefixed scope names to their backing scope-peer names.
Used by the read routes that accept a ``scope`` option (chat,
representation, session context, workspace search) to turn user-facing
scope names into the observer peers that implement them.
Args:
db: Database session
workspace_name: Name of the workspace
scope_names: Unprefixed scope names (duplicates are collapsed,
preserving first-seen order)
Returns:
The backing scope-peer names, in first-requested order
Raises:
ResourceNotFoundException: If any named scope does not exist
ValidationException: If a peer occupies a scope's reserved name
without the authoritative kind flag (a legacy collision)
"""
requested: list[str] = []
seen: set[str] = set()
for name in scope_names:
if name not in seen:
seen.add(name)
requested.append(name)
peer_names = [scope_peer_name(name) for name in requested]
if not peer_names:
return []
result = await db.execute(
select(models.Peer)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name.in_(peer_names))
)
peers_by_name = {peer.name: peer for peer in result.scalars().all()}
resolved: list[str] = []
for name, peer_name in zip(requested, peer_names, strict=True):
peer = peers_by_name.get(peer_name)
if peer is None:
raise ResourceNotFoundException(
f"Scope {name} not found in workspace {workspace_name}"
)
# The kind flag is authoritative and lives in internal_metadata, so a
# legacy peer merely occupying the reserved name is refused rather than
# silently treated as a scope.
if not is_scope_peer(peer.name, peer.internal_metadata):
raise ValidationException(
f"'{name}' does not name a scope: a non-scope peer occupies "
+ "its reserved name."
)
resolved.append(peer_name)
return resolved
async def get_scope_sessions(
workspace_name: str,
scope_name: str,

View File

@ -21,24 +21,27 @@ from src.utils.scopes import is_scope_peer
logger = logging.getLogger(__name__)
def _reject_scope_participants(*peers: models.Peer) -> None:
"""Refuse a dialectic run whose observer or observed is a scope peer.
def _reject_scope_observed(peer: models.Peer) -> None:
"""Refuse a dialectic run whose *observed* peer is a scope.
A scope is a silent observer with ``observe_me=false``: no representation of
one exists to query. Querying *from* a scope's perspective is a read-side
surface that does not exist yet, and not something the raw peer routes expose.
one exists to query, so it can never be the subject.
The observer position is deliberately NOT checked here. A single `scope` on
chat swaps the observer to the scope peer answering from a scope's
perspective is the entire point of that option so a guard here would reject
every scoped chat. The raw path peer is still refused as an observer, by the
route (``routers/peers.py``), where the distinction between "the caller named
a scope" and "the `scope` option resolved to one" is still visible.
Raises:
ValidationException: If any participant is a scope.
ValidationException: If the observed peer is a scope.
"""
offenders = sorted(
{p.name for p in peers if is_scope_peer(p.name, p.internal_metadata)}
)
if offenders:
if is_scope_peer(peer.name, peer.internal_metadata):
raise ValidationException(
f"Peer name(s) {offenders} are scopes."
f"Peer name '{peer.name}' is a scope."
+ " No representation is formed of a scope, so a scope cannot be a"
+ " dialectic observer or target."
+ " dialectic target."
)
@ -76,13 +79,13 @@ async def agentic_chat(
if observer != observed:
observed_peer = await crud.get_peer(db, workspace_name, observed)
# Resolved-row scope check, not a name check. The routes reject scope
# names up front for a clear error, but that runs before resolution: a
# scope created in between would otherwise be used here as observer or
# target. Checking the rows we just resolved closes that window — an
# absent name already failed above, and an existing unflagged squatter
# cannot retroactively become a scope.
_reject_scope_participants(observer_peer, observed_peer)
# Resolved-row scope check, not a name check. The routes reject a scope
# target up front for a clear error, but that runs before resolution: a
# scope created in between would otherwise be answered about here.
# Checking the row we just resolved closes that window — an absent name
# already failed above, and an existing unflagged squatter cannot
# retroactively become a scope.
_reject_scope_observed(observed_peer)
session = None
if session_name:
@ -157,13 +160,13 @@ async def agentic_chat_stream(
if observer != observed:
observed_peer = await crud.get_peer(db, workspace_name, observed)
# Resolved-row scope check, not a name check. The routes reject scope
# names up front for a clear error, but that runs before resolution: a
# scope created in between would otherwise be used here as observer or
# target. Checking the rows we just resolved closes that window — an
# absent name already failed above, and an existing unflagged squatter
# cannot retroactively become a scope.
_reject_scope_participants(observer_peer, observed_peer)
# Resolved-row scope check, not a name check. The routes reject a scope
# target up front for a clear error, but that runs before resolution: a
# scope created in between would otherwise be answered about here.
# Checking the row we just resolved closes that window — an absent name
# already failed above, and an existing unflagged squatter cannot
# retroactively become a scope.
_reject_scope_observed(observed_peer)
session = None
if session_name:

View File

@ -5,6 +5,7 @@ import logging
from collections.abc import AsyncIterator
from contextlib import suppress
from time import perf_counter
from typing import Any
from fastapi import APIRouter, Body, Depends, Path, Query, Response
from fastapi.responses import StreamingResponse
@ -28,7 +29,7 @@ from src.exceptions import (
from src.security import JWTParams, require_auth
from src.telemetry import prometheus_metrics
from src.telemetry.events import EmbeddingCallPurpose, GetContextEvent, emit
from src.utils.filter import extract_session_allowlist
from src.utils.filter import MAX_SESSION_ALLOWLIST_ENTRIES, extract_session_allowlist
from src.utils.schema_conversion import json_response_schema_to_pydantic
from src.utils.scopes import (
is_scope_peer,
@ -46,6 +47,73 @@ router = APIRouter(
)
def _validate_scope_option(
*,
filters: dict[str, Any] | None,
session_id: str | None,
jwt_params: JWTParams,
) -> None:
"""Enforce the v1 `scope` exclusions and auth rule (chat/representation).
`scope` is mutually exclusive with `filters` and `session_id` (422), and a
scope's member sessions may exceed a peer's own membership, so scoped
reads require a workspace- or admin-level key.
401 rather than 403: every other scope surface refuses a narrow key with 401
the `/scopes` router via `require_auth`, and the `scopes` field on session
create so a peer key would otherwise get two different codes for the same
feature depending on which side of it was touched.
"""
if filters is not None:
raise ValidationException("`scope` and `filters` are mutually exclusive")
if session_id:
raise ValidationException("`scope` and `session_id` are mutually exclusive")
if jwt_params.p is not None:
raise AuthenticationException(
"`scope` requires a workspace- or admin-level key"
)
async def _resolve_scope_option(
workspace_id: str,
scope: str | list[str],
*,
db_action: str,
) -> tuple[str | None, list[str] | None]:
"""Map a validated `scope` option to (observer_override, session_allowlist).
A single scope swaps the observer to the scope peer: conclusion recall is
then confined to the (scope, observed) collection and message recall to
the scope's session membership by existing observer semantics. A list of
scopes keeps the path peer as observer and returns the union of the
scopes' member sessions as an explicit allowlist (fail-closed when empty).
"""
async with tracked_db(db_action, read_only=True) as scope_db:
if isinstance(scope, str):
[scope_peer] = await crud.resolve_scope_peers(
scope_db, workspace_id, [scope]
)
return scope_peer, None
scope_peers = await crud.resolve_scope_peers(scope_db, workspace_id, scope)
union: list[str] = []
seen: set[str] = set()
for scope_peer in scope_peers:
for session_name in await get_peer_session_names(
scope_db, workspace_id, scope_peer
):
if session_name not in seen:
seen.add(session_name)
union.append(session_name)
if len(union) > MAX_SESSION_ALLOWLIST_ENTRIES:
raise ValidationException(
"The scopes' combined membership exceeds the maximum of "
+ f"{MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request"
)
return None, union
@router.post(
"/list",
response_model=Page[schemas.Peer],
@ -243,6 +311,22 @@ async def chat(
),
)
# Scoped reads: a single scope swaps the observer to the scope
# peer; a list of scopes becomes a session allowlist over their union.
observer = peer_id
scope_session_union: list[str] | None = None
if options.scope is not None:
_validate_scope_option(
filters=options.filters,
session_id=options.session_id,
jwt_params=jwt_params,
)
observer_override, scope_session_union = await _resolve_scope_option(
workspace_id, options.scope, db_action="peers.chat.resolve_scope"
)
if observer_override is not None:
observer = observer_override
# 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
@ -274,6 +358,12 @@ async def chat(
if not set(session_allowlist) <= member_sessions:
raise AuthenticationException("JWT not permissioned for this resource")
# A list of scopes resolves to a session allowlist over their union, which
# replaces any filters-derived allowlist (the two are mutually exclusive, so
# only one can be set).
if scope_session_union is not None:
session_allowlist = scope_session_union
# Convert the caller's JSON Schema so malformed schemas fail immediately with 422
response_model: type[BaseModel] | None = None
if options.response_format is not None:
@ -291,9 +381,12 @@ async def chat(
)
# Re-check on the resolved row: the name-level check above ran before the
# peer was resolved, so a scope created in between would be picked up here
# as existing and used as the chat observer.
observer = peers_result.resource[0]
if is_scope_peer(observer.name, observer.internal_metadata):
# as existing and used as the chat observer. Deliberately NOT named
# `observer` — that holds the effective observer, which a single `scope`
# has already swapped to the scope peer, and rebinding it here would
# silently undo the swap.
path_peer = peers_result.resource[0]
if is_scope_peer(path_peer.name, path_peer.internal_metadata):
raise ValidationException(
"No representation is formed of a scope, so a scope cannot be a "
+ "chat observer or target."
@ -325,7 +418,7 @@ async def chat(
workspace_name=workspace_id,
session_name=options.session_id,
query=options.query,
observer=peer_id,
observer=observer,
observed=options.target if options.target is not None else peer_id,
reasoning_level=options.reasoning_level,
session_allowlist=session_allowlist,
@ -339,7 +432,8 @@ async def chat(
workspace_name=workspace_id,
session_name=options.session_id,
query=options.query,
observer=peer_id,
# a single `scope` swaps the observer to the scope peer
observer=observer,
# if target is given, that's the observed peer. otherwise, observer==observed
# and it's answered from the omniscient Honcho perspective
observed=options.target if options.target is not None else peer_id,
@ -361,9 +455,6 @@ async def chat(
@router.post(
"/{peer_id}/representation",
response_model=schemas.RepresentationResponse,
dependencies=[
Depends(require_auth(workspace_name="workspace_id", peer_name="peer_id"))
],
)
async def get_representation(
workspace_id: str = Path(...),
@ -371,6 +462,9 @@ async def get_representation(
options: schemas.PeerRepresentationGet = Body(
..., description="Options for getting the peer representation"
),
jwt_params: JWTParams = Depends(
require_auth(workspace_name="workspace_id", peer_name="peer_id")
),
):
"""Get a curated subset of a Peer's Representation. A Representation is always a subset of the total
knowledge about the Peer. The subset can be scoped and filtered in various ways.
@ -407,6 +501,24 @@ async def get_representation(
options.filters, must_include=options.session_id
)
# Scoped reads: a single scope swaps the observer to the scope
# peer; a list of scopes becomes a session allowlist over their union.
observer = peer_id
scope_session_union: list[str] | None = None
if options.scope is not None:
_validate_scope_option(
filters=options.filters,
session_id=options.session_id,
jwt_params=jwt_params,
)
observer_override, scope_session_union = await _resolve_scope_option(
workspace_id, options.scope, db_action="peers.representation.resolve_scope"
)
if observer_override is not None:
observer = observer_override
if scope_session_union is not None:
session_allowlist = scope_session_union
try:
embedding: list[float] | None = None
if options.search_query:
@ -452,7 +564,8 @@ async def get_representation(
representation = await crud.get_working_representation(
workspace_id,
db=read_session,
observer=peer_id,
# a single `scope` swaps the observer to the scope peer
observer=observer,
observed=observed,
session_allowlist=[options.session_id]
if options.session_id is not None
@ -611,6 +724,25 @@ async def get_peer_context(
This is useful for getting all the context needed about a peer without
making multiple API calls.
"""
# Scope peers may not appear on the generic peer-context surface: no
# representation is formed of a scope, and scoped reads go through the
# `scope` option on chat/representation/session-context instead. Flag-based
# rather than prefix-based, so a legacy peer merely occupying the reserved
# name keeps working; strict on a reserved name that does not exist yet,
# since nothing here creates it. Costs no query when no reserved name is
# present, and runs before any embedding work.
scope_candidates = [
n for n in (peer_id, target) if n is not None and is_scope_peer_name(n)
]
if scope_candidates:
async with tracked_db("peers.context.scope_check", read_only=True) as s_db:
await crud.reject_scope_observed(
s_db,
workspace_id,
scope_candidates,
action="Use the `scope` option on the read routes instead.",
)
# If no target specified, get the peer's own context (self-observation)
observed = target if target is not None else peer_id
context_started = perf_counter()

View File

@ -675,19 +675,17 @@ async def get_session_peers(
@router.get(
"/{session_id}/context",
response_model=schemas.SessionContext,
dependencies=[
Depends(
require_auth(
workspace_name="workspace_id",
session_name="session_id",
allow_member_read=True,
)
)
],
)
async def get_session_context(
workspace_id: str = Path(...),
session_id: str = Path(...),
jwt_params: JWTParams = Depends(
require_auth(
workspace_name="workspace_id",
session_name="session_id",
allow_member_read=True,
)
),
db: AsyncSession = read_db,
tokens: int | None = Query(
None,
@ -712,6 +710,10 @@ async def get_session_context(
None,
description="A peer to get context for. If given, response will attempt to include representation and card from the perspective of that peer. Must be provided with `peer_target`.",
),
scope: str | None = Query(
None,
description="An (unprefixed) scope name to use as the perspective source: the representation and peer card of `peer_target` are read from the scope's observations instead of the global (or `peer_perspective`) view. Must be provided with `peer_target`; mutually exclusive with `peer_perspective`. Requires a workspace- or admin-level key.",
),
limit_to_session: bool = Query(
default=False,
description="Only used if `search_query` is provided. Whether to limit the representation to the session (as opposed to everything known about the target peer)",
@ -756,11 +758,9 @@ async def get_session_context(
)
# peer_target is the *observed* peer, and no representation or card is ever
# formed of a scope. peer_perspective (the observer) is left alone: a scope is
# a legitimate perspective, and the read-side scope surface will build on that.
# formed of a scope. Strict variant: an observed position that creates
# nothing, so a reserved name which does not exist yet must be refused too.
if peer_target is not None:
# Strict variant: an observed position that creates nothing, so a reserved
# name which does not exist yet must be refused too.
await crud.reject_scope_observed(
db,
workspace_id,
@ -771,6 +771,36 @@ async def get_session_context(
),
)
# peer_perspective is an observer position, where a scope is mechanically
# legitimate — but `scope` below is the supported way to ask for a scope's
# perspective, and routing through it is what keeps the observer mechanics
# hidden. Flag-based (not prefix-based) so a legacy peer merely occupying the
# reserved name keeps working, same as everywhere else.
if peer_perspective is not None:
await crud.reject_scope_peers(
db,
workspace_id,
[peer_perspective],
action="Use the `scope` parameter instead.",
)
if scope is not None:
if peer_perspective:
raise ValidationException(
"`scope` and `peer_perspective` are mutually exclusive"
)
if not peer_target:
raise ValidationException(
"peer_target must be provided if scope is provided"
)
# A scope's perspective spans sessions beyond this one, so scoped reads
# require a workspace- or admin-level key. 401, matching every other
# scope surface (see _validate_scope_option in routers/peers.py).
if jwt_params.p is not None or jwt_params.s is not None:
raise AuthenticationException(
"`scope` requires a workspace- or admin-level key"
)
if not peer_target:
# No representation or card needed
summary, messages = await _get_session_context_task(
@ -804,6 +834,24 @@ async def get_session_context(
observer = peer_perspective or peer_target
observed = peer_target
# Member-read lets a peer-scoped key reach this route, but membership grants
# access to the *session*, not to a co-member's representation or peer card.
# The observer is whose knowledge is being read, so a peer-scoped key may only
# read from its own perspective — mirroring
# `POST /peers/{peer_id}/representation`, where require_auth pins the observer
# to the path peer and any `target` is that observer's own view. A bare
# `peer_target` naming another peer is the omniscient view of them, which is
# nobody's own perspective, so it is refused too. Workspace/admin and
# session-scoped tokens are unaffected.
if jwt_params.p is not None and jwt_params.p != observer:
raise AuthenticationException("JWT not permissioned for this resource")
# A scope swaps the perspective source: the scope peer becomes the
# observer for both the working representation and the peer card, so the
# scoped collection and scoped card are read instead of the global ones.
if scope is not None:
[observer] = await crud.resolve_scope_peers(db, workspace_id, [scope])
# Pre-compute embedding outside the DB session (best-effort)
embedding: list[float] | None = None
if search_query:

View File

@ -7,11 +7,12 @@ from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
from src import crud, models, schemas
from src.config import settings
from src.dependencies import db, read_db
from src.crud.message import get_peer_session_names
from src.dependencies import db, read_db, tracked_db
from src.deriver.enqueue import enqueue_deletion, enqueue_dream
from src.exceptions import AuthenticationException
from src.exceptions import AuthenticationException, ValidationException
from src.security import JWTParams, require_auth
from src.utils.search import search
@ -141,16 +142,38 @@ async def delete_workspace(
)
async def search_workspace(
workspace_id: str = Path(...),
body: schemas.MessageSearchOptions = Body(
body: schemas.WorkspaceMessageSearchOptions = Body(
..., description="Message search parameters"
),
):
"""
Search messages in a Workspace using optional filters. Use `limit` to control the number of
results returned.
Pass `scope` to restrict the search to a scope's member sessions. A scope
with no member sessions returns no results (fail-closed).
"""
# take user-provided filter and add workspace_id to it
filters = body.filters or {}
if body.scope is not None:
if "session_id" in filters:
raise ValidationException(
"`scope` and a 'session_id' filter are mutually exclusive"
)
async with tracked_db(
"workspaces.search.resolve_scope", read_only=True
) as scope_db:
[scope_peer] = await crud.resolve_scope_peers(
scope_db, workspace_id, [body.scope]
)
scope_sessions = await get_peer_session_names(
scope_db, workspace_id, scope_peer
)
if not scope_sessions:
# A scope with no member sessions matches nothing, not everything.
no_results: list[models.Message] = []
return no_results
filters["session_id"] = {"in": scope_sessions}
filters["workspace_id"] = workspace_id
return await search(body.query, filters=filters, limit=body.limit)

View File

@ -55,6 +55,7 @@ from src.schemas.api import (
WorkspaceBase,
WorkspaceCreate,
WorkspaceGet,
WorkspaceMessageSearchOptions,
WorkspaceUpdate,
)
from src.schemas.configuration import (
@ -155,6 +156,7 @@ __all__ = [
"WorkspaceBase",
"WorkspaceCreate",
"WorkspaceGet",
"WorkspaceMessageSearchOptions",
"WorkspaceUpdate",
# internal
"DocumentBase",

View File

@ -12,6 +12,7 @@ from urllib.parse import urlparse
import tiktoken
from pydantic import (
AfterValidator,
AliasChoices,
BaseModel,
BeforeValidator,
@ -117,6 +118,19 @@ def _validate_scope_name(name: str) -> str:
return name
_ScopeName = Annotated[str, AfterValidator(_validate_scope_name)]
# The `scope` read option (chat / representation): one scope name, or a bounded
# list of them. The length cap sits on the list member so it bounds the *list* —
# a single name is already bounded by `_validate_scope_name`, and a union-level
# `max_length` would cap that name's characters instead. The upper bound matches
# `SessionCreate.scopes`; the lower one rejects `[]`, which would otherwise
# resolve to an empty allowlist and silently recall nothing.
_ScopeOption = (
_ScopeName | Annotated[list[_ScopeName], Field(min_length=1, max_length=100)]
)
# ---------------------------------------------------------------------------
# Workspace schemas
# ---------------------------------------------------------------------------
@ -245,6 +259,19 @@ class PeerRepresentationGet(BaseModel):
"must be included in the allowlist."
),
)
scope: _ScopeOption | None = Field(
None,
description=(
"Optional (unprefixed) scope name(s) to confine the representation. "
"A single scope reads the scope's own representation of the target "
"peer, formed only from the scope's member sessions. A list of "
"scopes restricts the representation to conclusions from the union "
"of the scopes' member sessions (explicit allowlist, fail-closed: "
"an empty union yields an empty representation). Mutually "
"exclusive with `filters` and `session_id`. Requires a workspace- "
"or admin-level key."
),
)
target: str | None = Field(
None,
description="Optional peer ID to get the representation for, from the perspective of this peer",
@ -695,6 +722,20 @@ class MessageSearchOptions(BaseModel):
return v.replace("\x00", "")
class WorkspaceMessageSearchOptions(MessageSearchOptions):
"""Workspace-level message search options, extended with `scope`."""
scope: str | None = Field(
default=None,
description=(
"Optional (unprefixed) scope name restricting search to the "
"scope's member sessions. A scope with no member sessions returns "
"no results. Mutually exclusive with a 'session_id' key in "
"`filters`."
),
)
# ---------------------------------------------------------------------------
# Dialectic schemas
# ---------------------------------------------------------------------------
@ -714,6 +755,19 @@ class DialecticOptions(BaseModel):
"also set, it must be included in the allowlist."
),
)
scope: _ScopeOption | None = Field(
None,
description=(
"Optional (unprefixed) scope name(s) to confine recall. A single "
"scope answers from the scope's own representation of the target "
"peer: conclusion recall is confined to what the scope observed "
"and message recall to the scope's member sessions. A list of "
"scopes restricts recall to the union of the scopes' member "
"sessions (explicit allowlist, fail-closed: an empty union "
"recalls nothing). Mutually exclusive with `filters` and "
"`session_id`. Requires a workspace- or admin-level key."
),
)
target: str | None = Field(
None,
description="Optional peer to get the representation for, from the perspective of this peer",

View File

@ -10,9 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
import pytest_asyncio
from cashews.backends.interface import ControlMixin
from cashews.picklers import PicklerType
from fakeredis import FakeAsyncRedis
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
@ -385,54 +383,32 @@ async def db_session(db_engine: AsyncEngine):
@pytest_asyncio.fixture(scope="session")
async def fake_cache_session():
"""Set up fakeredis for caching once per test session."""
"""Set up a taskless in-memory cache once per test session.
Cashews' normal memory backend starts a periodic expiry task on whichever
event loop first uses it. Tests use both pytest-asyncio loops and TestClient
portal loops, so that task can be cancelled when its originating loop closes
and then leak a CancelledError into the next app startup. Disabling the
periodic sweep keeps the backend loop-agnostic; expired entries are still
discarded lazily when read.
"""
# Store original settings
original_enabled = settings.CACHE.ENABLED
original_url = settings.CACHE.URL
# Create a fake redis instance that persists for the session
fake_redis = FakeAsyncRedis(decode_responses=True)
# Patch redis creation to use fakeredis
# Cashews uses redis.asyncio.from_url to create connections
def fake_redis_from_url(*_args: Any, **_kwargs: Any):
return fake_redis
# Patch the cashews backend's _disable property to avoid ContextVar issues
# This works around cashews' ContextVar not being properly initialized in TestClient context
original_disable_property = ControlMixin._disable # pyright: ignore[reportPrivateUsage]
@property # type: ignore
def patched_disable_property(self): # pyright: ignore
try:
return original_disable_property.fget(self) # pyright: ignore[reportOptionalCall]
except LookupError:
# Return empty set as default if ContextVar not set in current context
return set() # pyright: ignore
# Start patching
redis_patch = patch("redis.asyncio.from_url", fake_redis_from_url)
redis_patch.start()
ControlMixin._disable = patched_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue]
try:
# Enable caching and set URL for tests
# Use the same backend from pytest-asyncio and TestClient event loops.
settings.CACHE.ENABLED = True
settings.CACHE.URL = "redis://fake-redis:6379/0"
# Setup cache for tests that don't use TestClient (direct CRUD tests)
# For TestClient tests, the app's lifespan handler will also call cache.setup()
# The ContextVar patch above handles any context issues
settings.CACHE.URL = "mem://?check_interval=0"
cache.setup(
"redis://fake-redis:6379/0", pickle_type=PicklerType.SQLALCHEMY, enable=True
settings.CACHE.URL,
pickle_type=PicklerType.SQLALCHEMY,
enable=True,
)
yield fake_redis
yield cache
finally:
# Stop the patches
redis_patch.stop()
ControlMixin._disable = original_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue]
await cache.close()
# Restore original settings
settings.CACHE.ENABLED = original_enabled
@ -440,21 +416,21 @@ async def fake_cache_session():
@pytest_asyncio.fixture(scope="function", autouse=True)
async def fake_cache(fake_cache_session: FakeAsyncRedis):
async def fake_cache(fake_cache_session: Any): # pyright: ignore[reportUnusedParameter]
"""Clear cache between tests."""
# Clear cache before each test
await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType]
await cache.clear()
yield cache
# Clear cache after each test
await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType]
await cache.clear()
@pytest.fixture(scope="function")
async def client(
db_session: AsyncSession,
fake_cache_session: FakeAsyncRedis, # pyright: ignore[reportUnusedParameter]
fake_cache_session: Any, # pyright: ignore[reportUnusedParameter]
monkeypatch: pytest.MonkeyPatch,
) -> AsyncGenerator[TestClient, Any]:
"""Create a FastAPI TestClient for the scope of a single test function"""
@ -964,6 +940,7 @@ def mock_tracked_db(request: pytest.FixtureRequest):
"src.deriver.consumer.tracked_db",
"src.deriver.enqueue.tracked_db",
"src.routers.peers.tracked_db",
"src.routers.workspaces.tracked_db",
"src.crud.representation.tracked_db",
"src.dreamer.orchestrator.tracked_db",
"src.dreamer.dream_scheduler.tracked_db",

View File

@ -236,7 +236,7 @@ class TestRepresentationManagerSoftDelete:
class TestRepresentationManagerSessionScoping:
"""Tests that the session allowlist is applied uniformly to every query path.
Regression for DEV-1994: session_name used to be applied only to the
Regression: session_name used to be applied only to the
recent-documents query; the semantic and most-derived paths ignored it,
so limit_to_session leaked cross-session conclusions.
"""
@ -368,7 +368,7 @@ class TestRepresentationManagerSessionScoping:
assert mock_query.await_args.kwargs["filters"] == {
"session_name": {"in": [session_a.name]},
# Scoped recall serves only levels with a trustworthy session
# stamp (ALLOWLIST_SAFE_LEVELS / DEV-2201).
# stamp (ALLOWLIST_SAFE_LEVELS).
"level": {"in": ["explicit"]},
}
@ -445,7 +445,7 @@ class TestRepresentationManagerSessionScoping:
)
# Scoping also narrows to levels whose session stamp is trustworthy
# (see ALLOWLIST_SAFE_LEVELS / DEV-2201).
# (see ALLOWLIST_SAFE_LEVELS).
assert manager._build_filter_conditions(session_allowlist=[]) == { # pyright: ignore[reportPrivateUsage]
"session_name": {"in": []},
"level": {"in": ["explicit"]},

View File

@ -0,0 +1,82 @@
"""The scope guard inside the dialectic entry points.
The route tests mock `agentic_chat` wholesale (`mock_llm_call_functions` in
tests/conftest.py), so the preflight *inside* it has no coverage there which is
how a guard that rejected every scoped chat went unnoticed. These call it
directly with the agent stubbed, so no LLM work happens.
"""
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src.dialectic.chat import agentic_chat
from src.exceptions import ValidationException
from src.models import Peer, Workspace
from src.utils.scopes import scope_peer_name
async def _create_scope(
client: TestClient, db_session: AsyncSession, workspace_name: str
) -> str:
"""Create a scope and commit it — the preflight opens its own connection."""
scope_name = str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/scopes", json={"id": scope_name}
)
assert response.status_code in [200, 201]
await db_session.commit()
return scope_name
@pytest.mark.asyncio
async def test_scope_observer_reaches_the_agent(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A single `scope` swaps the observer to the scope peer, so the preflight must
let a scope through in the observer position otherwise every scoped chat 422s."""
workspace, peer = sample_data
scope_name = await _create_scope(client, db_session, workspace.name)
with patch("src.dialectic.chat.DialecticAgent") as agent_cls:
agent_cls.return_value.answer = AsyncMock(return_value="answered")
answer = await agentic_chat(
workspace_name=workspace.name,
session_name=None,
query="what do you know?",
observer=scope_peer_name(scope_name),
observed=peer.name,
)
assert answer == "answered"
assert agent_cls.call_args.kwargs["observer"] == scope_peer_name(scope_name)
@pytest.mark.asyncio
async def test_scope_observed_still_rejected(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""The invariant the guard exists for: no representation is formed of a scope,
so it can never be the subject even if the route's name check was raced."""
workspace, peer = sample_data
scope_name = await _create_scope(client, db_session, workspace.name)
with (
patch("src.dialectic.chat.DialecticAgent") as agent_cls,
pytest.raises(ValidationException, match=scope_peer_name(scope_name)),
):
await agentic_chat(
workspace_name=workspace.name,
session_name=None,
query="what do you know?",
observer=peer.name,
observed=scope_peer_name(scope_name),
)
agent_cls.assert_not_called()

View File

@ -0,0 +1,750 @@
"""Tests for the `scope` option on the read routes.
A single scope swaps the observer to the scope peer, so recall is confined to
the (scope, observed) collection and the scope's member sessions by existing
observer semantics. A list of scopes keeps the path peer as observer and
restricts recall to the union of the scopes' member sessions (the
session-allowlist arm).
"""
from typing import Any
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models
from src.config import settings
from src.models import Peer, Workspace
from src.security import JWTParams, create_jwt
from src.utils.scopes import scope_peer_name
def _create_scope(client: TestClient, workspace_name: str, scope_name: str):
response = client.post(
f"/v3/workspaces/{workspace_name}/scopes", json={"id": scope_name}
)
assert response.status_code in [200, 201]
return response
def _create_session(
client: TestClient,
workspace_name: str,
session_name: str | None = None,
**extra: Any,
) -> str:
session_name = session_name or str(generate_nanoid())
response = client.post(
f"/v3/workspaces/{workspace_name}/sessions",
json={"id": session_name, **extra},
)
assert response.status_code in [200, 201]
return session_name
def _add_sessions_to_scope(
client: TestClient, workspace_name: str, scope_name: str, session_names: list[str]
) -> None:
response = client.post(
f"/v3/workspaces/{workspace_name}/scopes/{scope_name}/sessions",
json={"session_ids": session_names},
)
assert response.status_code == 204, response.text
async def _seed_documents(
db_session: AsyncSession,
workspace_name: str,
*,
observer: str,
observed: str,
contents: list[tuple[str, str | None]],
) -> None:
"""Seed a collection plus documents for an (observer, observed) pair.
``contents`` is a list of (content, session_name) tuples.
"""
collection = models.Collection(
workspace_name=workspace_name,
observer=observer,
observed=observed,
)
db_session.add(collection)
await db_session.flush()
db_session.add_all(
[
models.Document(
workspace_name=workspace_name,
observer=observer,
observed=observed,
content=content,
session_name=session_name,
)
for content, session_name in contents
]
)
await db_session.commit()
async def _seed_legacy_collision_peer(
db_session: AsyncSession, workspace_name: str, scope_name: str
) -> None:
"""Create a plain peer squatting on a scope's reserved internal name."""
db_session.add(
models.Peer(
workspace_name=workspace_name,
name=scope_peer_name(scope_name),
)
)
await db_session.commit()
class TestScopeReadValidation:
"""4xx paths shared by chat and representation.
Chat validation happens before any LLM work, so these are safe to exercise.
"""
def _chat(
self, client: TestClient, workspace: Workspace, peer: Peer, body: dict[str, Any]
):
return client.post(
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat",
json={"query": "what do you know?", **body},
)
def _representation(
self, client: TestClient, workspace: Workspace, peer: Peer, body: dict[str, Any]
):
return client.post(
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation",
json=body,
)
def test_unknown_scope_404(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
unknown = str(generate_nanoid())
assert (
self._chat(client, workspace, peer, {"scope": unknown}).status_code == 404
)
assert (
self._representation(
client, workspace, peer, {"scope": unknown}
).status_code
== 404
)
def test_unknown_scope_in_list_404(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
resp = self._representation(
client, workspace, peer, {"scope": [scope_name, str(generate_nanoid())]}
)
assert resp.status_code == 404
async def test_non_scope_peer_as_scope_422(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A peer squatting on the reserved name without the kind flag is not a scope."""
workspace, peer = sample_data
scope_name = str(generate_nanoid())
await _seed_legacy_collision_peer(db_session, workspace.name, scope_name)
assert (
self._chat(client, workspace, peer, {"scope": scope_name}).status_code
== 422
)
assert (
self._representation(
client, workspace, peer, {"scope": scope_name}
).status_code
== 422
)
def test_scope_plus_filters_422(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
body = {"scope": scope_name, "filters": {"session_id": ["s1"]}}
assert self._chat(client, workspace, peer, body).status_code == 422
assert self._representation(client, workspace, peer, body).status_code == 422
def test_scope_plus_session_id_422(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
body = {"scope": scope_name, "session_id": "s1"}
assert self._chat(client, workspace, peer, body).status_code == 422
assert self._representation(client, workspace, peer, body).status_code == 422
def test_peer_scoped_jwt_401(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""A scope's sessions may exceed the peer's own membership: workspace/admin only.
401, matching every other scope surface see _validate_scope_option.
"""
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=workspace.name, p=peer.name))}"
)
assert (
self._chat(client, workspace, peer, {"scope": scope_name}).status_code
== 401
)
assert (
self._representation(
client, workspace, peer, {"scope": scope_name}
).status_code
== 401
)
# A session-scoped key gets the same answer, but from `require_auth`
# rather than from `_validate_scope_option`: these routes declare
# `peer_name` and no `session_name`, so an `s` token never reaches the
# handler at all. Asserted here so the handler's peer-only check stays
# sufficient — if either route ever starts declaring a session, this
# fails and the check needs the `s` arm the session-context route has.
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=workspace.name, s='any-session'))}"
)
assert (
self._chat(client, workspace, peer, {"scope": scope_name}).status_code
== 401
)
assert (
self._representation(
client, workspace, peer, {"scope": scope_name}
).status_code
== 401
)
# A workspace-level key is allowed through validation (404 here only
# if the scope were unknown; representation of an empty scope is 200).
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=workspace.name))}"
)
assert (
self._representation(
client, workspace, peer, {"scope": scope_name}
).status_code
== 200
)
def test_scope_union_cap_422(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
monkeypatch: pytest.MonkeyPatch,
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_a = _create_session(client, workspace.name)
session_b = _create_session(client, workspace.name)
_add_sessions_to_scope(
client, workspace.name, scope_name, [session_a, session_b]
)
monkeypatch.setattr("src.routers.peers.MAX_SESSION_ALLOWLIST_ENTRIES", 1)
resp = self._representation(client, workspace, peer, {"scope": [scope_name]})
assert resp.status_code == 422
assert "maximum" in resp.json()["detail"]
@pytest.mark.parametrize(
"scope",
[
pytest.param([], id="empty-list"),
pytest.param(["s"] * 101, id="over-list-cap"),
pytest.param([scope_peer_name("already-prefixed")], id="double-prefixed"),
pytest.param(["ok", "not a name!"], id="bad-charset-element"),
pytest.param(scope_peer_name("already-prefixed"), id="single-prefixed"),
],
)
def test_scope_option_bounds_422(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
scope: str | list[str],
):
"""Schema-level bounds on `scope`, before any scope is resolved: the list is
bounded at both ends, and every element is validated as an unprefixed scope
name (so a double-prefixed one is a 422, not a 404 for `scope.scope.x`)."""
workspace, peer = sample_data
assert self._chat(client, workspace, peer, {"scope": scope}).status_code == 422
assert (
self._representation(client, workspace, peer, {"scope": scope}).status_code
== 422
)
class TestRepresentationWithScope:
async def test_single_scope_reads_scope_collection(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A single scope swaps the observer: only the (scope, peer) collection is read."""
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_a = _create_session(client, workspace.name)
session_b = _create_session(client, workspace.name)
_add_sessions_to_scope(client, workspace.name, scope_name, [session_a])
# Conclusions the scope observed (session A) ...
await _seed_documents(
db_session,
workspace.name,
observer=scope_peer_name(scope_name),
observed=peer.name,
contents=[("scoped fact about hiking", session_a)],
)
# ... and global self-observations from another session
await _seed_documents(
db_session,
workspace.name,
observer=peer.name,
observed=peer.name,
contents=[("global fact about cooking", session_b)],
)
resp = client.post(
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation",
json={"scope": scope_name},
)
assert resp.status_code == 200
representation = resp.json()["representation"]
assert "scoped fact about hiking" in representation
assert "global fact about cooking" not in representation
async def test_scope_list_unions_member_sessions(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""A scope list keeps the global observer and applies the union allowlist."""
workspace, peer = sample_data
scope_a = str(generate_nanoid())
scope_b = str(generate_nanoid())
_create_scope(client, workspace.name, scope_a)
_create_scope(client, workspace.name, scope_b)
session_a = _create_session(client, workspace.name)
session_b = _create_session(client, workspace.name)
session_c = _create_session(client, workspace.name)
_add_sessions_to_scope(client, workspace.name, scope_a, [session_a])
_add_sessions_to_scope(client, workspace.name, scope_b, [session_b])
# All conclusions live in the GLOBAL (peer, peer) collection: only the
# union session-allowlist can explain the filtering below (this is the
# dynamic session-allowlist arm, not the observer swap).
await _seed_documents(
db_session,
workspace.name,
observer=peer.name,
observed=peer.name,
contents=[
("fact from session a", session_a),
("fact from session b", session_b),
("fact from session c", session_c),
("sessionless dream fact", None),
],
)
resp = client.post(
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation",
json={"scope": [scope_a, scope_b]},
)
assert resp.status_code == 200
representation = resp.json()["representation"]
assert "fact from session a" in representation
assert "fact from session b" in representation
assert "fact from session c" not in representation
assert "sessionless dream fact" not in representation
def test_empty_scope_list_fails_closed(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""A scope with no member sessions yields an empty representation."""
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
resp = client.post(
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/representation",
json={"scope": [scope_name]},
)
assert resp.status_code == 200
assert "fact" not in resp.json()["representation"]
class TestChatWithScope:
"""Verify what the chat route hands the dialectic, without real LLM work.
``agentic_chat`` is mocked in conftest (``mock_llm_call_functions``); the
scoped peer-card fetch happens inside it and is covered end-to-end by the
session-context test. Here we assert the route passes the right observer /
observed / session_names the wiring that keys the card fetch.
"""
def test_single_scope_swaps_observer(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
mock_llm_call_functions: dict[str, Any],
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
resp = client.post(
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat",
json={"query": "what do you know?", "scope": scope_name},
)
assert resp.status_code == 200
kwargs = mock_llm_call_functions["agentic_chat"].await_args.kwargs
# The scope peer is the observer; the path peer stays the observed
assert kwargs["observer"] == scope_peer_name(scope_name)
assert kwargs["observed"] == peer.name
# Single-scope confinement rides on observer semantics, not an allowlist
assert kwargs["session_allowlist"] is None
def test_scope_list_passes_union_allowlist(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
mock_llm_call_functions: dict[str, Any],
):
workspace, peer = sample_data
scope_a = str(generate_nanoid())
scope_b = str(generate_nanoid())
_create_scope(client, workspace.name, scope_a)
_create_scope(client, workspace.name, scope_b)
session_a = _create_session(client, workspace.name)
session_b = _create_session(client, workspace.name)
_add_sessions_to_scope(client, workspace.name, scope_a, [session_a])
_add_sessions_to_scope(client, workspace.name, scope_b, [session_b])
resp = client.post(
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/chat",
json={"query": "what do you know?", "scope": [scope_a, scope_b]},
)
assert resp.status_code == 200
kwargs = mock_llm_call_functions["agentic_chat"].await_args.kwargs
# Union path: the path peer stays the observer, the allowlist is the union
assert kwargs["observer"] == peer.name
assert kwargs["observed"] == peer.name
assert set(kwargs["session_allowlist"]) == {session_a, session_b}
class TestWorkspaceSearchWithScope:
def _seed_message(
self, client: TestClient, workspace_name: str, session_name: str, peer: Peer
) -> None:
resp = client.post(
f"/v3/workspaces/{workspace_name}/sessions/{session_name}/messages",
json={
"messages": [{"peer_id": peer.name, "content": "needle in haystack"}]
},
)
assert resp.status_code == 201
def test_search_restricted_to_scope_sessions(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_a = _create_session(client, workspace.name, peers={peer.name: {}})
session_b = _create_session(client, workspace.name, peers={peer.name: {}})
_add_sessions_to_scope(client, workspace.name, scope_name, [session_a])
self._seed_message(client, workspace.name, session_a, peer)
self._seed_message(client, workspace.name, session_b, peer)
# Unscoped: both sessions' messages match
resp = client.post(
f"/v3/workspaces/{workspace.name}/search",
json={"query": "needle"},
)
assert resp.status_code == 200
assert {m["session_id"] for m in resp.json()} == {session_a, session_b}
# Scoped: only the scope's member session
resp = client.post(
f"/v3/workspaces/{workspace.name}/search",
json={"query": "needle", "scope": scope_name},
)
assert resp.status_code == 200
results = resp.json()
assert results
assert {m["session_id"] for m in results} == {session_a}
def test_empty_scope_returns_no_results(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_a = _create_session(client, workspace.name, peers={peer.name: {}})
self._seed_message(client, workspace.name, session_a, peer)
resp = client.post(
f"/v3/workspaces/{workspace.name}/search",
json={"query": "needle", "scope": scope_name},
)
assert resp.status_code == 200
assert resp.json() == []
def test_unknown_scope_404(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, _ = sample_data
resp = client.post(
f"/v3/workspaces/{workspace.name}/search",
json={"query": "needle", "scope": str(generate_nanoid())},
)
assert resp.status_code == 404
def test_scope_plus_session_id_filter_422(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, _ = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
resp = client.post(
f"/v3/workspaces/{workspace.name}/search",
json={
"query": "needle",
"scope": scope_name,
"filters": {"session_id": "s1"},
},
)
assert resp.status_code == 422
class TestSessionContextWithScope:
async def test_scope_swaps_perspective_source(
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""`scope` reads the scope's collection and the scoped peer card."""
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
_add_sessions_to_scope(client, workspace.name, scope_name, [session_name])
await _seed_documents(
db_session,
workspace.name,
observer=scope_peer_name(scope_name),
observed=peer.name,
contents=[("scoped fact about hiking", session_name)],
)
await _seed_documents(
db_session,
workspace.name,
observer=peer.name,
observed=peer.name,
contents=[("global fact about cooking", session_name)],
)
await crud.set_peer_card(
db_session,
workspace.name,
peer_card=["SCOPED CARD"],
observer=scope_peer_name(scope_name),
observed=peer.name,
)
await crud.set_peer_card(
db_session,
workspace.name,
peer_card=["GLOBAL CARD"],
observer=peer.name,
observed=peer.name,
)
await db_session.commit()
# Without scope: the global (self) perspective
resp = client.get(
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
params={"peer_target": peer.name},
)
assert resp.status_code == 200
data = resp.json()
assert "global fact about cooking" in data["peer_representation"]
assert data["peer_card"] == ["GLOBAL CARD"]
# With scope: the scope's perspective
resp = client.get(
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
params={"peer_target": peer.name, "scope": scope_name},
)
assert resp.status_code == 200
data = resp.json()
assert "scoped fact about hiking" in data["peer_representation"]
assert "global fact about cooking" not in data["peer_representation"]
assert data["peer_card"] == ["SCOPED CARD"]
def test_scope_requires_peer_target(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
resp = client.get(
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
params={"scope": scope_name},
)
assert resp.status_code == 422
def test_scope_and_peer_perspective_mutually_exclusive(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
resp = client.get(
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
params={
"peer_target": peer.name,
"peer_perspective": peer.name,
"scope": scope_name,
},
)
assert resp.status_code == 422
def test_unknown_scope_404(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
resp = client.get(
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
params={"peer_target": peer.name, "scope": str(generate_nanoid())},
)
assert resp.status_code == 404
def test_narrow_keys_rejected_401(
self,
client: TestClient,
sample_data: tuple[Workspace, Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""Peer- and session-scoped keys may not widen reads through a scope."""
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
_add_sessions_to_scope(client, workspace.name, scope_name, [session_name])
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
url = f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context"
params = {"peer_target": peer.name, "scope": scope_name}
# Peer-scoped key (member read grants access to the route, not to scope)
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=workspace.name, p=peer.name))}"
)
assert client.get(url, params=params).status_code == 401
# Session-scoped key
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=workspace.name, s=session_name))}"
)
assert client.get(url, params=params).status_code == 401
# Workspace-scoped key is allowed
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=workspace.name))}"
)
assert client.get(url, params=params).status_code == 200
class TestScopePeerGuardrailClosure:
"""Scope peers are rejected on the generic perspective/context surfaces."""
def test_session_context_rejects_scope_peer_target(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
resp = client.get(
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
params={"peer_target": scope_peer_name(scope_name)},
)
assert resp.status_code == 422
def test_session_context_rejects_scope_peer_perspective(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
session_name = _create_session(client, workspace.name, peers={peer.name: {}})
resp = client.get(
f"/v3/workspaces/{workspace.name}/sessions/{session_name}/context",
params={
"peer_target": peer.name,
"peer_perspective": scope_peer_name(scope_name),
},
)
assert resp.status_code == 422
def test_peer_context_rejects_scope_peer(
self, client: TestClient, sample_data: tuple[Workspace, Peer]
):
workspace, peer = sample_data
scope_name = str(generate_nanoid())
_create_scope(client, workspace.name, scope_name)
# As the path-level peer
resp = client.get(
f"/v3/workspaces/{workspace.name}/peers/{scope_peer_name(scope_name)}/context"
)
assert resp.status_code == 422
# As the target
resp = client.get(
f"/v3/workspaces/{workspace.name}/peers/{peer.name}/context",
params={"target": scope_peer_name(scope_name)},
)
assert resp.status_code == 422

View File

@ -14,6 +14,16 @@ persisted a conclusion about something that carries ``observe_me=false``. One
route, two positions, opposite verdicts. The same split applies to
`schedule_dream`, the peer-card routes, and session context.
One refinement, added with the `scope` read option: on the *read* routes an
observer position is refused too, even though a scope there is mechanically
legitimate. Asking for a scope's perspective is what `scope` is for, and routing
through it is what keeps the observer mechanics hidden so `peer_perspective`,
`GET /peers/{peer_id}/context`, chat and representation all refuse a raw scope
peer name and point at `scope` instead. The invariant above still governs the
storage side, where `observer_id` / `observer` remain ALLOW: a scope observing is
the entire mechanism. Read "OBSERVER" as "may observe", not "may be named as one
on any route".
So classification here is keyed by ``(method, path, position)``, where position is
the request parameter carrying the peer name. Every derived triple must appear in
`POLICY` as either REFUSE or ALLOW-with-a-reason; a new one fails
@ -253,6 +263,14 @@ def _b_card_observer_get(c: TestClient, ws: str, _s: str, p: str):
return c.get(f"/v3/workspaces/{ws}/peers/{p}/card?target={_OTHER}")
def _b_peer_context_observer(c: TestClient, ws: str, _s: str, p: str):
return c.get(f"/v3/workspaces/{ws}/peers/{p}/context")
def _b_peer_context_target(c: TestClient, ws: str, _s: str, p: str):
return c.get(f"/v3/workspaces/{ws}/peers/{_OTHER}/context?target={p}")
def _b_context_perspective(c: TestClient, ws: str, s: str, p: str):
query = f"?peer_perspective={p}&peer_target={_OTHER}"
return c.get(f"/v3/workspaces/{ws}/sessions/{s}/context{query}")
@ -387,10 +405,15 @@ POLICY: tuple[Case, ...] = (
"GET",
f"{_W}/sessions/{{session_id}}/context",
"peer_perspective",
False,
reason=_OBSERVER_OK,
True,
refuse_missing=False,
missing_reason=(
"The perspective peer is resolved before the flag-based guard runs, so a "
"reserved name that does not exist yet is a 404 — the same answer any "
"absent peer gets here — and nothing on this path creates it."
),
missing_status=(404,),
build=_b_context_perspective,
allow_status=(200,),
),
Case(
"GET",
@ -552,21 +575,17 @@ POLICY: tuple[Case, ...] = (
"GET",
f"{_W}/peers/{{peer_id}}/context",
"peer_id",
False,
reason=(
"Read-only. The read-side scope surface is not implemented yet; the "
"`scope` option on the context routes will own it when it lands."
),
True,
refuse_missing=True,
build=_b_peer_context_observer,
),
Case(
"GET",
f"{_W}/peers/{{peer_id}}/context",
"target",
False,
reason=(
"Read-only, and empty for a scope now that nothing can write knowledge "
"about one. The read-side scope surface is not implemented yet."
),
True,
refuse_missing=True,
build=_b_peer_context_target,
),
Case(
"GET",

View File

@ -7,7 +7,9 @@ from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.models import Peer, Workspace
from src.security import JWTParams, create_jwt
def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
@ -1284,6 +1286,59 @@ def test_get_session_context_with_peer_perspective(
assert "peer_card" in data
@pytest.mark.asyncio
async def test_get_session_context_peer_key_denied_for_co_member_perspective(
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
monkeypatch: pytest.MonkeyPatch,
):
"""`allow_member_read` gets a peer-scoped key onto this route, but it may only
read from its OWN perspective. A co-member's representation and peer card are
not session data, so membership must not hand them over."""
test_workspace, alice = sample_data
bob = str(generate_nanoid())
client.post(
f"/v3/workspaces/{test_workspace.name}/peers",
json={"name": bob, "metadata": {}},
)
session_id = str(generate_nanoid())
client.post(
f"/v3/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {alice.name: {}, bob: {}}},
)
# Membership is read on a separate committed-only connection by the auth
# dependency, so it must be committed before a member-scoped read.
await db_session.commit()
monkeypatch.setattr(settings.AUTH, "USE_AUTH", True)
monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret")
client.headers["Authorization"] = (
f"Bearer {create_jwt(JWTParams(w=test_workspace.name, p=alice.name))}"
)
url = f"/v3/workspaces/{test_workspace.name}/sessions/{session_id}/context"
# Bob's view of alice — alice is not the observer.
assert (
client.get(
url, params={"peer_target": alice.name, "peer_perspective": bob}
).status_code
== 401
)
# The omniscient view of bob — nobody's own perspective.
assert client.get(url, params={"peer_target": bob}).status_code == 401
# Alice's own perspective on bob is hers to read, as is her own global view.
assert (
client.get(
url, params={"peer_target": bob, "peer_perspective": alice.name}
).status_code
== 200
)
assert client.get(url, params={"peer_target": alice.name}).status_code == 200
# Session data itself is still readable by any member.
assert client.get(url).status_code == 200
def test_get_session_context_peer_perspective_without_target_fails(
client: TestClient, sample_data: tuple[Workspace, Peer]
):

View File

@ -39,6 +39,9 @@ Tests are defined in JSON files. A test definition consists of a name, optional
* `create_session`: Create a new session, optionally with peers and config.
* `add_message`: Add a single message.
* `add_messages`: Add multiple messages.
* `create_scope`: Create a scope and optionally add member sessions. Add the
sessions *before* the messages you want in scope — membership only affects
messages ingested after a session joins.
3. **Waiting**:
* `wait`: Wait for duration or "queue_empty".
@ -46,6 +49,18 @@ Tests are defined in JSON files. A test definition consists of a name, optional
4. **Querying & Assertions**:
* `query`: Perform an action and assert on the result.
* `target`: "chat", "get_context", "get_peer_card", "get_representation"
* `scope`: confine the read to a scope (or, for chat/representation, to
the union of several). Valid for "chat", "get_representation" and
"get_context"; the latter takes a single scope and requires
`observed_peer_id`.
### Raw HTTP vs the SDK
Most steps drive the Honcho Python SDK. `create_scope` and any query carrying
`scope` go over raw HTTP instead, because the published SDK trails the API and
exposes neither. Calling the API directly also tests the contract the SDK is
generated from, so a wrong status code or response shape surfaces here rather
than being masked by client-side validation.
### Assertions

View File

@ -37,6 +37,7 @@ from tests.unified.schema import (
AddMessageAction,
AddMessagesAction,
ContainsAssertion,
CreateScopeAction,
CreateSessionAction,
ExactMatchAssertion,
JsonMatchAssertion,
@ -215,6 +216,38 @@ class UnifiedTestExecutor:
self.client: Honcho = honcho_client
self.anthropic: AsyncAnthropic | None = anthropic_client
# --- raw HTTP -----------------------------------------------------------
# Some surfaces (scopes, the `scope` read option) exist in the API before the
# published SDK exposes them. Calling them directly also tests the contract
# the SDK is generated from, so a wrong status or shape surfaces here instead
# of being masked by client-side validation.
@property
def workspace_id(self) -> str:
workspace_id = getattr(self.client, "workspace_id", None)
if not workspace_id:
raise ValueError("Honcho client has no workspace_id")
return str(workspace_id)
async def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
"""Call a /v3 workspace-scoped path directly, raising on error status."""
url = f"{str(self.client.base_url).rstrip('/')}/v3/workspaces/{self.workspace_id}{path}"
# Carry the same credential the SDK resolved (from `HONCHO_API_KEY`, unless
# passed explicitly). The harness sets no AUTH vars of its own, so auth is
# off by default — but it inherits `AUTH_USE_AUTH` from the environment,
# and these raw calls are the only ones here that would not be authorized.
headers: dict[str, str] = dict(kwargs.pop("headers", None) or {})
api_key = getattr(getattr(self.client, "_http", None), "api_key", None)
if api_key:
headers.setdefault("Authorization", f"Bearer {api_key}")
async with httpx.AsyncClient(timeout=120.0) as raw:
response = await raw.request(method, url, headers=headers, **kwargs)
if response.is_error:
raise AssertionError(
f"{method} {path} failed: {response.status_code} {response.text[:400]}"
)
return response
async def execute(self, test_def: TestDefinition, test_name: str) -> bool:
logger.info(f"Starting test: {test_name}")
@ -311,6 +344,15 @@ class UnifiedTestExecutor:
)
await session.aio.add_messages(msgs)
elif isinstance(step, CreateScopeAction):
await self._request("POST", "/scopes", json={"id": step.scope_id})
if step.session_ids:
await self._request(
"POST",
f"/scopes/{step.scope_id}/sessions",
json={"session_ids": step.session_ids},
)
elif isinstance(step, WaitAction):
if step.duration:
await asyncio.sleep(step.duration)
@ -344,6 +386,9 @@ class UnifiedTestExecutor:
raise TimeoutError("Deriver queue did not empty within timeout")
async def perform_query(self, step: QueryAction) -> Any:
if step.scope is not None:
return await self._perform_scoped_query(step)
if step.target == "chat":
if not step.observer_peer_id:
raise ValueError("observer_peer_id required for chat")
@ -395,6 +440,60 @@ class UnifiedTestExecutor:
return None
async def _perform_scoped_query(self, step: QueryAction) -> Any:
"""Run a `scope`-confined read over raw HTTP (no SDK parameter for it)."""
if step.target == "chat":
if not step.observer_peer_id:
raise ValueError("observer_peer_id required for chat")
if step.input is None:
raise ValueError("input required for chat")
body: dict[str, Any] = {"query": step.input, "scope": step.scope}
if step.session_id:
body["session_id"] = step.session_id
if step.observed_peer_id:
body["target"] = step.observed_peer_id
if step.reasoning_level:
body["reasoning_level"] = step.reasoning_level
response = await self._request(
"POST", f"/peers/{step.observer_peer_id}/chat", json=body
)
return response.json()["content"]
if step.target == "get_representation":
if not step.observer_peer_id:
raise ValueError("observer_peer_id required for get_representation")
body = {"scope": step.scope}
if step.observed_peer_id:
body["target"] = step.observed_peer_id
if step.input:
body["search_query"] = step.input
response = await self._request(
"POST", f"/peers/{step.observer_peer_id}/representation", json=body
)
return response.json()["representation"]
if step.target == "get_context":
if not step.session_id:
raise ValueError("session_id required for get_context")
if not step.observed_peer_id:
raise ValueError("observed_peer_id required for a scoped get_context")
# `scope` on session context takes a single scope name.
if isinstance(step.scope, list):
raise ValueError("get_context accepts a single scope, not a list")
params: dict[str, Any] = {
"scope": step.scope,
"peer_target": step.observed_peer_id,
"summary": str(step.summary).lower(),
}
if step.max_tokens is not None:
params["tokens"] = step.max_tokens
response = await self._request(
"GET", f"/sessions/{step.session_id}/context", params=params
)
return response.json()
raise ValueError(f"`scope` is not supported for target {step.target!r}")
async def check_assertion(self, result: Any, assertion: Any):
result_str = str(result)

View File

@ -63,6 +63,22 @@ class AddMessagesAction(TestStep):
messages: list[MessageItem]
class CreateScopeAction(TestStep):
"""Create a scope and optionally add member sessions.
Driven over raw HTTP rather than the SDK: scopes are a new API surface the
published SDK does not expose yet, and gating coverage on an SDK release
would leave the feature untested at exactly the point it needs testing.
"""
step_type: Literal["create_scope"] = "create_scope"
scope_id: str = Field(..., description="Unprefixed scope name")
session_ids: list[str] = Field(
default_factory=list,
description="Existing sessions to add as members of the scope",
)
# --- Wait Actions ---
@ -152,6 +168,11 @@ class QueryAction(TestStep):
# for chat - optional JSON Schema the response must conform to
response_format: dict[str, Any] | None = None
# Confine the read to one scope (observer swap) or to the union of several
# scopes' member sessions. Forces the raw-HTTP path, since the SDK has no
# `scope` parameter. Valid for chat, get_representation and get_context.
scope: str | list[str] | None = None
assertions: list[
LLMJudgeAssertion
| ContainsAssertion
@ -174,6 +195,7 @@ class TestDefinition(BaseModel):
| CreateSessionAction
| AddMessageAction
| AddMessagesAction
| CreateScopeAction
| WaitAction
| ScheduleDreamAction
| QueryAction,

View File

@ -0,0 +1,120 @@
{
"description": "A scope confines recall to its member sessions. Alice states one fact in a session that belongs to the 'work' scope and a different, contradictory-sounding fact in a session outside it. A scoped read must surface only the in-scope fact; the unscoped read sees both. This is the observer swap: the scope peer is the observer, so conclusion recall comes from the (scope, alice) collection and message recall from the scope's membership.",
"steps": [
{
"step_type": "create_session",
"session_id": "work_session",
"description": "In-scope session",
"peer_configs": {
"alice": { "observe_me": true },
"assistant": { "observe_others": true }
}
},
{
"step_type": "create_session",
"session_id": "personal_session",
"description": "Out-of-scope session — must never leak into a scoped read",
"peer_configs": {
"alice": { "observe_me": true },
"assistant": { "observe_others": true }
}
},
{
"step_type": "create_scope",
"scope_id": "work",
"session_ids": ["work_session"],
"description": "Scope covers only work_session. Membership must precede the messages: it only affects messages ingested after the session joins."
},
{
"step_type": "add_messages",
"session_id": "work_session",
"messages": [
{
"peer_id": "alice",
"content": "I'm a staff platform engineer and I work primarily in Rust."
},
{
"peer_id": "alice",
"content": "My current project is migrating our billing service off Postgres triggers."
}
]
},
{
"step_type": "add_messages",
"session_id": "personal_session",
"messages": [
{
"peer_id": "alice",
"content": "Outside work I'm training for a marathon in Chicago this October."
},
{
"peer_id": "alice",
"content": "I've been learning to play the upright bass on weekends."
}
]
},
{
"step_type": "wait",
"target": "queue_empty",
"flush": true
},
{
"step_type": "query",
"target": "get_representation",
"observer_peer_id": "assistant",
"observed_peer_id": "alice",
"scope": "work",
"description": "Scoped representation: only what the scope observed.",
"assertions": [
{
"assertion_type": "llm_judge",
"prompt": "Does this text describe Alice's professional life (engineering, Rust, or the billing/Postgres project) WITHOUT mentioning marathon running, Chicago, or the upright bass? Answer true only if the professional material is present and the personal material is entirely absent.",
"pass_if": true
},
{
"assertion_type": "not_contains",
"text": "marathon"
},
{
"assertion_type": "not_contains",
"text": "bass"
}
]
},
{
"step_type": "query",
"target": "chat",
"observer_peer_id": "assistant",
"observed_peer_id": "alice",
"scope": "work",
"input": "What do you know about Alice's hobbies outside of work?",
"description": "A scoped chat cannot answer from out-of-scope sessions, so it should report not knowing rather than surfacing the marathon or the bass.",
"assertions": [
{
"assertion_type": "llm_judge",
"prompt": "Does this response indicate that it does not know about Alice's hobbies outside work, or only discuss her professional life? Answer false if it mentions marathon running, Chicago, or playing the bass.",
"pass_if": true
},
{
"assertion_type": "not_contains",
"text": "marathon"
}
]
},
{
"step_type": "query",
"target": "chat",
"observer_peer_id": "assistant",
"observed_peer_id": "alice",
"input": "What do you know about Alice's hobbies outside of work?",
"description": "Control: the same question unscoped. Proves the scoped result above is the scope working, not the deriver simply having failed to record the personal session.",
"assertions": [
{
"assertion_type": "llm_judge",
"prompt": "Does this response mention marathon running, Chicago, or playing the upright bass? Answer true if at least one of Alice's out-of-work hobbies is described.",
"pass_if": true
}
]
}
]
}

26
uv.lock
View File

@ -8,7 +8,7 @@ resolution-markers = [
]
[options]
exclude-newer = "2026-08-05T17:35:29.589887Z"
exclude-newer = "2026-08-07T22:25:26.806369Z"
exclude-newer-span = "P5D"
[manifest]
@ -730,19 +730,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]
[[package]]
name = "fakeredis"
version = "2.35.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "redis" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/50/b748233c02fa77e5105238190cc9bb58b852eb1c8b1d0763230d3a5b745a/fakeredis-2.35.1.tar.gz", hash = "sha256:5bae5eba7b9d93cb968944ac40936373cf2397ff71667d4b595df65c3d2e413f", size = 189118, upload-time = "2026-04-12T17:05:58.539Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/27/b8b057a23f7777177e92d3a602fd866751b6b45014964548997e92e048fd/fakeredis-2.35.1-py3-none-any.whl", hash = "sha256:67d97e11f562b7870e11e5c30cf182270bfb2dd37f6707dba47cc6d91628d1b9", size = 129678, upload-time = "2026-04-12T17:05:56.86Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
@ -1200,7 +1187,6 @@ dev = [
{ name = "basedpyright" },
{ name = "boto3" },
{ name = "coverage" },
{ name = "fakeredis" },
{ name = "honcho-ai" },
{ name = "interrogate" },
{ name = "pre-commit" },
@ -1254,7 +1240,6 @@ dev = [
{ name = "basedpyright", specifier = ">=1.29.4" },
{ name = "boto3", specifier = ">=1.42.5" },
{ name = "coverage", specifier = ">=7.6.0" },
{ name = "fakeredis", specifier = ">=2.32.0" },
{ name = "honcho-ai", editable = "sdks/python" },
{ name = "interrogate", specifier = ">=1.7.0" },
{ name = "pre-commit", specifier = ">=4.2.0" },
@ -3611,15 +3596,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.49"