diff --git a/docs/v3/documentation/features/advanced/using-filters.mdx b/docs/v3/documentation/features/advanced/using-filters.mdx
index 306312ce..2a015c03 100644
--- a/docs/v3/documentation/features/advanced/using-filters.mdx
+++ b/docs/v3/documentation/features/advanced/using-filters.mdx
@@ -614,6 +614,65 @@ messages = session.messages(filters={
```
+### Filtering Conclusions
+
+Conclusions are scoped to an observer/observed peer pair (accessed via
+`peer.conclusions` for self-conclusions or `peer.conclusions_of(target)` for
+conclusions about another peer). The observer and observed are filled in
+automatically by the scope, so the `filters` you pass add to them.
+
+The most useful conclusion-specific field is `level`, the reasoning level:
+
+- `explicit` — extracted directly from messages
+- `deductive` / `inductive` / `contradiction` — derived later during dreaming
+
+A common request is to surface only the directly-stated facts and exclude
+anything inferred during dreaming — filter `level` to `explicit`:
+
+
+```python Python
+# Only conclusions extracted directly from messages (exclude dream-derived)
+explicit = peer.conclusions.list(filters={"level": "explicit"})
+
+# Only dream-derived conclusions
+derived = peer.conclusions.list(filters={"level": {"in": ["deductive", "inductive"]}})
+
+# Same filtering on semantic search
+results = peer.conclusions.query(
+ "food preferences",
+ filters={"level": "deductive"},
+)
+
+# Conclusions about another peer, explicit only
+bob_explicit = peer.conclusions_of("bob").list(filters={"level": "explicit"})
+```
+
+```typescript TypeScript
+(async () => {
+ // Only conclusions extracted directly from messages (exclude dream-derived)
+ const explicit = await peer.conclusions.list({ filters: { level: "explicit" } });
+
+ // Only dream-derived conclusions
+ const derived = await peer.conclusions.list({
+ filters: { level: { in: ["deductive", "inductive"] } }
+ });
+
+ // Same filtering on semantic search (query, topK, distance, filters)
+ const results = await peer.conclusions.query(
+ "food preferences",
+ 10,
+ undefined,
+ { level: "deductive" }
+ );
+
+ // Conclusions about another peer, explicit only
+ const bobExplicit = await peer.conclusionsOf("bob").list({
+ filters: { level: "explicit" }
+ });
+})();
+```
+
+
## Error Handling
Handle filter errors gracefully:
diff --git a/sdks/python/src/honcho/aio.py b/sdks/python/src/honcho/aio.py
index 9ff27a12..2cf39ab1 100644
--- a/sdks/python/src/honcho/aio.py
+++ b/sdks/python/src/honcho/aio.py
@@ -47,7 +47,11 @@ from .api_types import (
WorkspaceResponse,
)
from .base import PeerBase, SessionBase
-from .conclusions import Conclusion
+from .conclusions import (
+ _SCOPE_RESERVED,
+ Conclusion,
+ _reject_reserved_filter_keys,
+)
from .http import routes
from .message import Message
from .mixins import AsyncMetadataConfigMixin
@@ -1460,17 +1464,28 @@ class ConclusionScopeAio:
size: int = 50,
session: str | SessionBase | None = None,
*,
+ filters: dict[str, Any] | None = None,
reverse: bool = False,
) -> AsyncPage[ConclusionResponse, Conclusion]:
- """List conclusions in this scope asynchronously."""
+ """List conclusions in this scope asynchronously.
+
+ Pass ``filters`` to add criteria merged with this scope's
+ observer/observed (and session, if given) — e.g.
+ ``{"level": "explicit"}`` to get only conclusions extracted directly
+ from messages (i.e. not derived during dreaming). See
+ https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
+ """
+ _reject_reserved_filter_keys(
+ filters, _SCOPE_RESERVED + ("session", "session_id")
+ )
await self._scope._honcho._ensure_workspace_async()
resolved_session_id = resolve_id(session)
- filters: dict[str, Any] = {
+ filters = {
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
+ **({"session_id": resolved_session_id} if resolved_session_id else {}),
+ **(filters or {}),
}
- if resolved_session_id:
- filters["session_id"] = resolved_session_id
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
@@ -1504,12 +1519,24 @@ class ConclusionScopeAio:
query: str,
top_k: int = 10,
distance: float | None = None,
+ *,
+ filters: dict[str, Any] | None = None,
) -> list[Conclusion]:
- """Semantic search for conclusions asynchronously."""
+ """Semantic search for conclusions asynchronously.
+
+ Args:
+ query: The search query string
+ top_k: Maximum number of results to return
+ distance: Maximum cosine distance threshold (0.0-1.0)
+ filters: Optional dictionary of additional filter criteria, merged
+ with this scope's observer/observed (e.g. ``{"level": "deductive"}``).
+ """
+ _reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
await self._scope._honcho._ensure_workspace_async()
- filters: dict[str, Any] = {
+ filters = {
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
+ **(filters or {}),
}
body: dict[str, Any] = {
diff --git a/sdks/python/src/honcho/api_types.py b/sdks/python/src/honcho/api_types.py
index 897a86fe..64ee7b65 100644
--- a/sdks/python/src/honcho/api_types.py
+++ b/sdks/python/src/honcho/api_types.py
@@ -10,6 +10,10 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
+# Reasoning level of a conclusion. "explicit" conclusions are extracted directly
+# from messages; the others are derived during dreaming.
+ConclusionLevel = Literal["explicit", "deductive", "inductive", "contradiction"]
+
# ==============================================================================
# Configuration Types
# ==============================================================================
@@ -414,6 +418,7 @@ class ConclusionResponse(BaseModel):
observer_id: str
observed_id: str
session_id: str | None = None
+ level: ConclusionLevel = "explicit"
created_at: datetime.datetime
diff --git a/sdks/python/src/honcho/conclusions.py b/sdks/python/src/honcho/conclusions.py
index e76b1900..708cc3ed 100644
--- a/sdks/python/src/honcho/conclusions.py
+++ b/sdks/python/src/honcho/conclusions.py
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
-from .api_types import ConclusionResponse, RepresentationResponse
+from .api_types import ConclusionLevel, ConclusionResponse, RepresentationResponse
from .base import SessionBase
from .http import routes
from .pagination import SyncPage
@@ -24,6 +24,34 @@ __all__ = [
"ConclusionCreateParams",
]
+# Filter keys that define a conclusion scope (the observer/observed peer pair).
+# They are set from the scope itself, so a caller must not pass them in `filters`.
+_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id")
+
+
+def _reject_reserved_filter_keys(
+ filters: dict[str, Any] | None, reserved: tuple[str, ...]
+) -> None:
+ """Raise if ``filters`` contains keys managed by the conclusion scope.
+
+ The observer/observed peer pair (and, on ``list``, the session) is fixed by
+ the scope, so letting a user filter override it would silently return data
+ from a different scope than requested. Fail loud instead.
+ """
+ if not filters:
+ return
+ clash = sorted(k for k in reserved if k in filters)
+ if clash:
+ guidance = (
+ "Choose the peer pair via peer.conclusions / peer.conclusions_of(target)"
+ )
+ if "session" in reserved or "session_id" in reserved:
+ guidance += "; use the session= parameter to filter by session"
+ raise ValueError(
+ f"Filter key(s) {clash} are managed by this conclusion scope and "
+ + f"cannot be passed in filters. {guidance}."
+ )
+
class ConclusionCreateParams(BaseModel):
content: str
@@ -43,6 +71,9 @@ class Conclusion:
observer_id: The peer ID who made this conclusion
observed_id: The peer ID this conclusion is about
session_id: The session this conclusion relates to
+ level: Reasoning level ("explicit", "deductive", "inductive",
+ "contradiction"). "explicit" conclusions are extracted directly
+ from messages; the others are derived during dreaming.
created_at: Timestamp for when the conclusion was created
"""
@@ -51,6 +82,7 @@ class Conclusion:
observer_id: str
observed_id: str
session_id: str | None = None
+ level: ConclusionLevel = "explicit"
created_at: datetime.datetime
def __init__(
@@ -61,12 +93,14 @@ class Conclusion:
observed_id: str,
session_id: str | None,
created_at: datetime.datetime,
+ level: ConclusionLevel = "explicit",
) -> None:
self.id = id
self.content = content
self.observer_id = observer_id
self.observed_id = observed_id
self.session_id = session_id
+ self.level = level
self.created_at = created_at
@classmethod
@@ -78,6 +112,7 @@ class Conclusion:
observer_id=data.observer_id,
observed_id=data.observed_id,
session_id=data.session_id,
+ level=data.level,
created_at=data.created_at,
)
@@ -169,6 +204,7 @@ class ConclusionScope:
size: int = 50,
session: str | SessionBase | None = None,
*,
+ filters: dict[str, Any] | None = None,
reverse: bool = False,
) -> SyncPage[ConclusionResponse, Conclusion]:
"""
@@ -178,19 +214,28 @@ class ConclusionScope:
page: Page number (1-indexed)
size: Number of results per page
session: Optional session (ID string or Session object) to filter by
+ filters: Optional dictionary of additional filter criteria, merged
+ with this scope's observer/observed (and session, if given).
+ Supports the same operators as other list endpoints — e.g.
+ ``{"level": "explicit"}`` to get only conclusions extracted
+ directly from messages (i.e. not derived during dreaming). See
+ https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
reverse: If True, reverses the default ordering. Default: False.
Returns:
Paginated response containing Conclusion objects
"""
+ _reject_reserved_filter_keys(
+ filters, _SCOPE_RESERVED + ("session", "session_id")
+ )
self._honcho._ensure_workspace()
resolved_session_id = resolve_id(session)
- filters: dict[str, Any] = {
+ filters = {
"observer_id": self.observer,
"observed_id": self.observed,
+ **({"session_id": resolved_session_id} if resolved_session_id else {}),
+ **(filters or {}),
}
- if resolved_session_id:
- filters["session_id"] = resolved_session_id
query: dict[str, Any] = {"page": page, "size": size}
if reverse:
@@ -224,6 +269,8 @@ class ConclusionScope:
query: str,
top_k: int = 10,
distance: float | None = None,
+ *,
+ filters: dict[str, Any] | None = None,
) -> list[Conclusion]:
"""
Semantic search for conclusions in this scope.
@@ -232,14 +279,21 @@ class ConclusionScope:
query: The search query string
top_k: Maximum number of results to return
distance: Maximum cosine distance threshold (0.0-1.0)
+ filters: Optional dictionary of additional filter criteria, merged
+ with this scope's observer/observed. Supports the same operators
+ as the list endpoint — e.g. ``{"level": "deductive"}`` to search
+ only conclusions derived during dreaming. See
+ https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
Returns:
List of matching Conclusion objects
"""
+ _reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
self._honcho._ensure_workspace()
- filters: dict[str, Any] = {
+ filters = {
"observer_id": self.observer,
"observed_id": self.observed,
+ **(filters or {}),
}
body: dict[str, Any] = {
diff --git a/sdks/typescript/__tests__/conclusions.test.ts b/sdks/typescript/__tests__/conclusions.test.ts
index 8e7a6a09..7716d3ac 100644
--- a/sdks/typescript/__tests__/conclusions.test.ts
+++ b/sdks/typescript/__tests__/conclusions.test.ts
@@ -278,6 +278,61 @@ describe('Conclusions', () => {
})
})
+ // ===========================================================================
+ // Scope-reserved filter guard
+ // ===========================================================================
+
+ describe('reserved filter keys', () => {
+ test('list rejects observer/observed scope keys in filters', async () => {
+ const peer = await client.peer('reserved-list-peer', { metadata: {} })
+
+ for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
+ await expect(
+ peer.conclusions.list({ filters: { [key]: 'someone-else' } })
+ ).rejects.toThrow(/managed by this conclusion scope/)
+ }
+ })
+
+ test('list rejects session keys in filters (use the session option)', async () => {
+ const peer = await client.peer('reserved-list-session-peer', { metadata: {} })
+
+ await expect(
+ peer.conclusions.list({ filters: { session_id: 'sess' } })
+ ).rejects.toThrow(/managed by this conclusion scope/)
+ await expect(
+ peer.conclusions.list({ filters: { session: 'sess' } })
+ ).rejects.toThrow(/managed by this conclusion scope/)
+ })
+
+ test('query rejects observer/observed scope keys in filters', async () => {
+ const peer = await client.peer('reserved-query-peer', { metadata: {} })
+
+ for (const key of ['observer', 'observed', 'observer_id', 'observed_id']) {
+ await expect(
+ peer.conclusions.query('q', 10, undefined, { [key]: 'someone-else' })
+ ).rejects.toThrow(/managed by this conclusion scope/)
+ }
+ })
+
+ test('query allows session_id in filters (no dedicated session param)', async () => {
+ const peer = await client.peer('reserved-query-session-peer', { metadata: {} })
+
+ // Should not throw the reserved-key guard; session_id is a normal filter
+ // for query. The call may return no matches, which is fine.
+ await expect(
+ peer.conclusions.query('q', 10, undefined, { session_id: 'sess' })
+ ).resolves.toBeDefined()
+ })
+
+ test('non-reserved filters (level) still work on list', async () => {
+ const peer = await client.peer('reserved-allowed-peer', { metadata: {} })
+
+ await expect(
+ peer.conclusions.list({ filters: { level: 'explicit' } })
+ ).resolves.toBeDefined()
+ })
+ })
+
// ===========================================================================
// Conclusion Deletion (DELETE /conclusions/:id)
// ===========================================================================
diff --git a/sdks/typescript/src/conclusions.ts b/sdks/typescript/src/conclusions.ts
index 7c854b57..c9add4e2 100644
--- a/sdks/typescript/src/conclusions.ts
+++ b/sdks/typescript/src/conclusions.ts
@@ -3,6 +3,7 @@ import type { HonchoHTTPClient } from './http/client'
import { Page } from './pagination'
import type { Session } from './session'
import type {
+ ConclusionLevel,
ConclusionResponse,
PageResponse,
RepresentationOptions,
@@ -10,6 +11,43 @@ import type {
} from './types/api'
import { normalizeSearchQuery, RepresentationOptionsSchema } from './validation'
+/**
+ * Filter keys that define a conclusion scope (the observer/observed peer pair).
+ * They are set from the scope itself, so a caller must not pass them in `filters`.
+ */
+const SCOPE_RESERVED_KEYS = [
+ 'observer',
+ 'observed',
+ 'observer_id',
+ 'observed_id',
+]
+
+/**
+ * Throw if `filters` contains keys managed by the conclusion scope.
+ *
+ * The observer/observed peer pair (and, on `list`, the session) is fixed by the
+ * scope, so letting a user filter override it would silently return data from a
+ * different scope than requested. Fail loud instead.
+ */
+function rejectReservedFilterKeys(
+ filters: Record | undefined,
+ reserved: string[]
+): void {
+ if (!filters) return
+ const clash = reserved.filter((k) => k in filters).sort()
+ if (clash.length > 0) {
+ let guidance =
+ 'Choose the peer pair via peer.conclusions / peer.conclusionsOf(target)'
+ if (reserved.includes('session') || reserved.includes('session_id')) {
+ guidance += '; use the session option to filter by session'
+ }
+ throw new Error(
+ `Filter key(s) ${clash.join(', ')} are managed by this conclusion scope ` +
+ `and cannot be passed in filters. ${guidance}.`
+ )
+ }
+}
+
/**
* Parameters for creating a conclusion.
*/
@@ -32,6 +70,12 @@ export class Conclusion {
readonly observerId: string
readonly observedId: string
readonly sessionId: string | null
+ /**
+ * Reasoning level: 'explicit' conclusions are extracted directly from
+ * messages; 'deductive'/'inductive'/'contradiction' are derived during
+ * dreaming.
+ */
+ readonly level: ConclusionLevel
readonly createdAt: string
constructor(
@@ -40,13 +84,15 @@ export class Conclusion {
observerId: string,
observedId: string,
sessionId: string | null,
- createdAt: string
+ createdAt: string,
+ level: ConclusionLevel = 'explicit'
) {
this.id = id
this.content = content
this.observerId = observerId
this.observedId = observedId
this.sessionId = sessionId
+ this.level = level
this.createdAt = createdAt
}
@@ -57,7 +103,8 @@ export class Conclusion {
data.observer_id,
data.observed_id,
data.session_id,
- data.created_at
+ data.created_at,
+ data.level
)
}
@@ -182,14 +229,26 @@ export class ConclusionScope {
* @param options.page - Page number (1-indexed, default: 1)
* @param options.size - Number of items per page (default: 50)
* @param options.session - Optional session (ID string or Session object) to filter by
+ * @param options.filters - Optional additional filter criteria, merged with
+ * this scope's observer/observed (and session, if given). Supports the same
+ * operators as other list endpoints — e.g. `{ level: 'explicit' }` to get
+ * only conclusions extracted directly from messages (i.e. not derived during
+ * dreaming). See
+ * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
* @returns Promise resolving to a Page of Conclusion objects
*/
async list(options?: {
page?: number
size?: number
session?: string | Session
+ filters?: Record
reverse?: boolean
}): Promise> {
+ rejectReservedFilterKeys(options?.filters, [
+ ...SCOPE_RESERVED_KEYS,
+ 'session',
+ 'session_id',
+ ])
const resolvedSessionId = options?.session
? typeof options.session === 'string'
? options.session
@@ -198,9 +257,8 @@ export class ConclusionScope {
const filters: Record = {
observer_id: this.observer,
observed_id: this.observed,
- }
- if (resolvedSessionId) {
- filters.session_id = resolvedSessionId
+ ...(resolvedSessionId ? { session_id: resolvedSessionId } : {}),
+ ...options?.filters,
}
const reverse = options?.reverse
@@ -227,22 +285,32 @@ export class ConclusionScope {
/**
* Semantic search for conclusions in this scope.
+ *
+ * @param query - The search query string
+ * @param topK - Maximum number of results to return (default: 10)
+ * @param distance - Maximum cosine distance threshold (0.0-1.0)
+ * @param filters - Optional additional filter criteria, merged with this
+ * scope's observer/observed. Supports the same operators as the list
+ * endpoint — e.g. `{ level: 'deductive' }` to search only conclusions
+ * derived during dreaming. See
+ * https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
*/
async query(
query: string,
topK: number = 10,
- distance?: number
+ distance?: number,
+ filters?: Record
): Promise {
- const filters: Record = {
- observer_id: this.observer,
- observed_id: this.observed,
- }
-
+ rejectReservedFilterKeys(filters, SCOPE_RESERVED_KEYS)
const response = await this._query({
query,
top_k: topK,
distance,
- filters,
+ filters: {
+ observer_id: this.observer,
+ observed_id: this.observed,
+ ...filters,
+ },
})
return (response ?? []).map((item) => Conclusion.fromApiResponse(item))
diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts
index f6b11d26..90bff9f2 100644
--- a/sdks/typescript/src/index.ts
+++ b/sdks/typescript/src/index.ts
@@ -40,6 +40,7 @@ export {
// API types (snake_case, for advanced usage)
export type {
+ ConclusionLevel,
ConclusionQueryParams,
ConclusionResponse,
MessageResponse,
diff --git a/sdks/typescript/src/types/api.ts b/sdks/typescript/src/types/api.ts
index 65c6d88c..dda25ad1 100644
--- a/sdks/typescript/src/types/api.ts
+++ b/sdks/typescript/src/types/api.ts
@@ -242,12 +242,23 @@ export interface MessageSearchParams {
// Conclusion Types
// =============================================================================
+/**
+ * Reasoning level of a conclusion. "explicit" conclusions are extracted
+ * directly from messages; the others are derived during dreaming.
+ */
+export type ConclusionLevel =
+ | 'explicit'
+ | 'deductive'
+ | 'inductive'
+ | 'contradiction'
+
export interface ConclusionResponse {
id: string
content: string
observer_id: string
observed_id: string
session_id: string | null
+ level: ConclusionLevel
created_at: string
}
diff --git a/src/schemas/api.py b/src/schemas/api.py
index 4863d99a..a276c4b7 100644
--- a/src/schemas/api.py
+++ b/src/schemas/api.py
@@ -29,6 +29,7 @@ from src.schemas.configuration import (
SessionPeerConfig,
WorkspaceConfiguration,
)
+from src.utils.types import DocumentLevel
# ---------------------------------------------------------------------------
# Metadata validation helpers
@@ -446,6 +447,14 @@ class Conclusion(BaseModel):
serialization_alias="observed_id",
)
session_name: str | None = Field(default=None, serialization_alias="session_id")
+ level: DocumentLevel = Field(
+ default="explicit",
+ description=(
+ "Reasoning level of the conclusion: 'explicit' (directly extracted "
+ "from messages) or 'deductive'/'inductive'/'contradiction' (derived "
+ "during dreaming)."
+ ),
+ )
created_at: datetime.datetime
model_config = ConfigDict( # pyright: ignore
diff --git a/tests/routes/test_conclusions.py b/tests/routes/test_conclusions.py
index a2ef56a0..77cb23fb 100644
--- a/tests/routes/test_conclusions.py
+++ b/tests/routes/test_conclusions.py
@@ -737,6 +737,7 @@ class TestConclusionRoutes:
assert conclusion["observer_id"] == doc.observer
assert conclusion["observed_id"] == doc.observed
assert conclusion["session_id"] == doc.session_name
+ assert conclusion["level"] == "explicit"
assert "created_at" in conclusion
# Verify internal fields are NOT exposed
@@ -744,6 +745,80 @@ class TestConclusionRoutes:
assert "internal_metadata" not in conclusion
assert "collection" not in conclusion
+ @pytest.mark.asyncio
+ async def test_list_conclusions_filter_by_level(
+ self,
+ client: TestClient,
+ db_session: AsyncSession,
+ sample_data: tuple[Workspace, Peer],
+ ):
+ """Filtering by `level` returns only conclusions at that reasoning level.
+
+ `level="explicit"` is the "not dreamed on" view — it excludes the
+ deductive/inductive conclusions produced during dreaming.
+ """
+ test_workspace, test_peer = sample_data
+
+ test_peer2 = models.Peer(
+ name=str(generate_nanoid()), workspace_name=test_workspace.name
+ )
+ db_session.add(test_peer2)
+ await db_session.flush()
+
+ test_session = models.Session(
+ name=str(generate_nanoid()), workspace_name=test_workspace.name
+ )
+ db_session.add(test_session)
+ await db_session.commit()
+
+ await self._create_collection(
+ db_session, test_workspace.name, test_peer.name, test_peer2.name
+ )
+
+ # Two explicit, one deductive, one inductive
+ levels = ["explicit", "explicit", "deductive", "inductive"]
+ for i, level in enumerate(levels):
+ db_session.add(
+ models.Document(
+ workspace_name=test_workspace.name,
+ observer=test_peer.name,
+ observed=test_peer2.name,
+ content=f"{level} conclusion {i}",
+ embedding=[0.1] * 1536,
+ session_name=test_session.name,
+ level=level,
+ )
+ )
+ await db_session.commit()
+
+ # No level filter -> all four
+ all_resp = client.post(
+ f"/v3/workspaces/{test_workspace.name}/conclusions/list",
+ json={"filters": {"session_id": test_session.name}},
+ )
+ assert all_resp.status_code == 200
+ assert all_resp.json()["total"] == 4
+
+ # level="explicit" -> only the two non-dreamed conclusions
+ explicit_resp = client.post(
+ f"/v3/workspaces/{test_workspace.name}/conclusions/list",
+ json={"filters": {"session_id": test_session.name, "level": "explicit"}},
+ )
+ assert explicit_resp.status_code == 200
+ explicit_data = explicit_resp.json()
+ assert explicit_data["total"] == 2
+ assert all(item["level"] == "explicit" for item in explicit_data["items"])
+
+ # level="deductive" -> only the one deductive conclusion
+ deductive_resp = client.post(
+ f"/v3/workspaces/{test_workspace.name}/conclusions/list",
+ json={"filters": {"session_id": test_session.name, "level": "deductive"}},
+ )
+ assert deductive_resp.status_code == 200
+ deductive_data = deductive_resp.json()
+ assert deductive_data["total"] == 1
+ assert deductive_data["items"][0]["level"] == "deductive"
+
@pytest.mark.asyncio
async def test_create_conclusion_success(
self,
diff --git a/tests/sdk/test_conclusions.py b/tests/sdk/test_conclusions.py
index 33331de3..9c2cdbc0 100644
--- a/tests/sdk/test_conclusions.py
+++ b/tests/sdk/test_conclusions.py
@@ -769,3 +769,73 @@ async def test_observation_create_mixed_session_and_sessionless(
assert session_obs.session_id == session.id
assert global_obs.session_id is None
+
+
+@pytest.mark.asyncio
+async def test_list_rejects_reserved_scope_filter_keys(
+ client_fixture: tuple[Honcho, str],
+):
+ """`list` rejects observer/observed/session filter keys managed by the scope.
+
+ These keys are fixed by the scope (observer/observed) or by the dedicated
+ ``session=`` parameter, so passing them in ``filters`` would silently return
+ data from a different scope. The guard raises before any HTTP call.
+ """
+ honcho_client, client_type = client_fixture
+ reserved = [
+ "observer",
+ "observed",
+ "observer_id",
+ "observed_id",
+ "session_id",
+ "session",
+ ]
+
+ if client_type == "async":
+ observer = await honcho_client.aio.peer(id="test-obs-reserved-list-observer")
+ target = await honcho_client.aio.peer(id="test-obs-reserved-list-target")
+ obs_scope = observer.conclusions_of(target)
+ for key in reserved:
+ with pytest.raises(ValueError, match="managed by this conclusion scope"):
+ await obs_scope.aio.list(filters={key: "someone-else"})
+ # A non-reserved filter (level) is allowed through.
+ await obs_scope.aio.list(filters={"level": "explicit"})
+ else:
+ observer = honcho_client.peer(id="test-obs-reserved-list-observer")
+ target = honcho_client.peer(id="test-obs-reserved-list-target")
+ obs_scope = observer.conclusions_of(target)
+ for key in reserved:
+ with pytest.raises(ValueError, match="managed by this conclusion scope"):
+ obs_scope.list(filters={key: "someone-else"})
+ obs_scope.list(filters={"level": "explicit"})
+
+
+@pytest.mark.asyncio
+async def test_query_rejects_reserved_scope_filter_keys(
+ client_fixture: tuple[Honcho, str],
+):
+ """`query` rejects observer/observed filter keys but allows session_id.
+
+ Unlike ``list``, ``query`` has no dedicated session parameter, so
+ ``session_id`` remains a normal filter and must NOT be rejected.
+ """
+ honcho_client, client_type = client_fixture
+ reserved = ["observer", "observed", "observer_id", "observed_id"]
+
+ if client_type == "async":
+ observer = await honcho_client.aio.peer(id="test-obs-reserved-query-observer")
+ target = await honcho_client.aio.peer(id="test-obs-reserved-query-target")
+ obs_scope = observer.conclusions_of(target)
+ for key in reserved:
+ with pytest.raises(ValueError, match="managed by this conclusion scope"):
+ await obs_scope.aio.query("q", filters={key: "someone-else"})
+ # session_id is a normal filter for query (no dedicated param) — allowed.
+ await obs_scope.aio.query("q", filters={"session_id": "some-session"})
+ else:
+ observer = honcho_client.peer(id="test-obs-reserved-query-observer")
+ target = honcho_client.peer(id="test-obs-reserved-query-target")
+ obs_scope = observer.conclusions_of(target)
+ for key in reserved:
+ with pytest.raises(ValueError, match="managed by this conclusion scope"):
+ obs_scope.query("q", filters={key: "someone-else"})
+ obs_scope.query("q", filters={"session_id": "some-session"})