diff --git a/CHANGELOG.md b/CHANGELOG.md
index bba1168..4da2c7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
All notable changes to **flycanon** are documented here.
+## [26.10.6] - 2026-10-26
+
+### Fixed
+
+- Apply nonempty `source_ids` membership before the BM25 and dense candidate windows, Reciprocal Rank Fusion and `top_k` selection. Lower-ranked admitted sources are no longer lost behind unrelated higher-ranked candidates.
+- Intersect source membership with the authenticated tenant and workspace in both retrieval channels. A selected source with no matching chunks returns no hits; retrieval never falls back to the global source set. Hydrated filtering remains defense in depth.
+- Preserve the existing request schema and the meaning of omitted, `null` and empty-list source selectors: no additional source constraint within the authenticated scope. Knowledge metadata filters retain their hydration behavior.
+
+### Distribution
+
+- Align server and Python/Java SDK version metadata at `26.10.6`. SDK search methods, runtime dependency pins, database migrations, source intake and EDA event schemas are unchanged.
+- Retain pyfly `v26.09.04` and Agentic `26.10.1`. Upgrade the server to obtain the source-filter correction; compatible existing SDKs continue to work.
+
## [26.10.5] - 2026-10-17
### Fixed
diff --git a/README.md b/README.md
index eb193a9..b1b081e 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ over whole documents with citations — all behind a single HTTP service.
[](https://github.com/fireflyframework/fireflyframework-agentic)
[](docs/api-reference.md)
[](docs/architecture.md#retrieval-backend-pluggable-dense-store)
-[](#)
+[](#)
[](LICENSE)
diff --git a/docs/api-reference.md b/docs/api-reference.md
index d63c97e..1c0c752 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -118,6 +118,8 @@ for private dev stacks only.
| `POST` | `/api/v1/query/stream` | Same as `/query` but streams the answer as Server-Sent Events. |
| `POST` | `/api/v1/query/suggest` | Suggest follow-up questions for an existing answer. Body: `AnswerRequest`. |
+A nonempty `source_ids` selector is intersected with the authenticated tenant and workspace before BM25 and dense candidate ranking, fusion and `top_k` selection. Sources with no matching chunks return no hits; retrieval never falls back to the global source set. Omitting `source_ids`, using `null`, or supplying an empty list retains the existing meaning of no additional source constraint within the authenticated scope. Hydrated source checks remain active after ranking as defense in depth. Other knowledge metadata filters retain their documented hydration behavior.
+
### Answer mode (RLM default / RAG deprecated)
The answer endpoints (`/api/v1/query`, `/api/v1/query/stream`, and the
diff --git a/docs/deployment.md b/docs/deployment.md
index 97914ec..cebc43c 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -43,7 +43,7 @@ For a host installation using the released wheel, follow [Install the released s
```bash
docker pull ghcr.io/fireflyframework/flycanon:latest # latest main
-docker pull ghcr.io/fireflyframework/flycanon:26.10.5 # specific release
+docker pull ghcr.io/fireflyframework/flycanon:26.10.6 # specific release
docker pull ghcr.io/fireflyframework/flycanon:26.10 # latest 26.10.x
```
@@ -683,12 +683,12 @@ The recommended upgrade dance:
```bash
# 1. Pull the new image
-docker pull ghcr.io/fireflyframework/flycanon:26.10.5
+docker pull ghcr.io/fireflyframework/flycanon:26.10.6
# 2. Run migrations against the live database
docker run --rm \
--env-file .env \
- ghcr.io/fireflyframework/flycanon:26.10.5 migrate
+ ghcr.io/fireflyframework/flycanon:26.10.6 migrate
# 3. Roll the API + worker deployments
docker compose --env-file .env up -d
diff --git a/docs/released-server-install.md b/docs/released-server-install.md
index c0ba4a5..57f51c1 100644
--- a/docs/released-server-install.md
+++ b/docs/released-server-install.md
@@ -6,13 +6,13 @@ Use the released source archive for the operator configuration and migration fil
## Download and verify
-Download `flycanon-26.10.5-py3-none-any.whl`, `flycanon-26.10.5.tar.gz`, `runtime-requirements-26.10.5.txt`, and `SHA256SUMS` from the [26.10.5 release](https://github.com/fireflyframework/flycanon/releases/tag/v26.10.5). Verify the downloaded files against their matching checksum entries before extracting or installing them.
+Download `flycanon-26.10.6-py3-none-any.whl`, `flycanon-26.10.6.tar.gz`, `runtime-requirements-26.10.6.txt`, and `SHA256SUMS` from the [26.10.6 release](https://github.com/fireflyframework/flycanon/releases/tag/v26.10.6). Verify the downloaded files against their matching checksum entries before extracting or installing them.
```bash
-tar -xzf flycanon-26.10.5.tar.gz
-cd flycanon-26.10.5
+tar -xzf flycanon-26.10.6.tar.gz
+cd flycanon-26.10.6
uv venv --python 3.13 .venv
-uv pip install --python .venv/bin/python -r ../runtime-requirements-26.10.5.txt
+uv pip install --python .venv/bin/python -r ../runtime-requirements-26.10.6.txt
```
The requirements file selects the released server wheel and these canonical framework references:
@@ -26,7 +26,7 @@ The equivalent installation without a requirements file is:
```bash
uv pip install --python .venv/bin/python \
- 'flycanon @ https://github.com/fireflyframework/flycanon/releases/download/v26.10.5/flycanon-26.10.5-py3-none-any.whl' \
+ 'flycanon @ https://github.com/fireflyframework/flycanon/releases/download/v26.10.6/flycanon-26.10.6-py3-none-any.whl' \
'pyfly @ git+https://github.com/fireflyframework/fireflyframework-pyfly.git@v26.09.04' \
'fireflyframework-agentic @ https://github.com/fireflyframework/fireflyframework-agentic/releases/download/v26.10.1/fireflyframework_agentic-26.10.1-py3-none-any.whl'
```
@@ -39,11 +39,11 @@ This release retains pyfly `v26.09.04` and the existing PostgreSQL EDA outbox. A
## Configure the runtime
-Keep `pyfly.yaml`, `alembic.ini`, `migrations/`, and `env_template` from the source archive in the working directory. The wheel does not supply the root-level `pyfly.yaml`. The release also provides a byte-identical `pyfly-26.10.5.yaml` copy for operators who already have a migration scaffold; install it as `pyfly.yaml` in the runtime working directory.
+Keep `pyfly.yaml`, `alembic.ini`, `migrations/`, and `env_template` from the source archive in the working directory. The wheel does not supply the root-level `pyfly.yaml`. The release also provides a byte-identical `pyfly-26.10.6.yaml` copy for operators who already have a migration scaffold; install it as `pyfly.yaml` in the runtime working directory.
The configuration selects the PostgreSQL event adapter and separate API/worker consumer groups. Starting without it can activate framework defaults, including an attempted Kafka connection to `localhost:9092` and a separate management listener. Do not rely on those defaults for the released service.
-Set the database URL, embedding model and dimensions, provider credentials, platform API keys, object-storage location, and optional Redis settings for your environment. Use [`env_template`](https://github.com/fireflyframework/flycanon/blob/v26.10.5/env_template) and the [deployment guide](https://github.com/fireflyframework/flycanon/blob/v26.10.5/docs/deployment.md) for the exact settings. Store real secrets outside published configuration and supply them through your operator's environment or secret manager.
+Set the database URL, embedding model and dimensions, provider credentials, platform API keys, object-storage location, and optional Redis settings for your environment. Use [`env_template`](https://github.com/fireflyframework/flycanon/blob/v26.10.6/env_template) and the [deployment guide](https://github.com/fireflyframework/flycanon/blob/v26.10.6/docs/deployment.md) for the exact settings. Store real secrets outside published configuration and supply them through your operator's environment or secret manager.
Provision the PostgreSQL database, required extensions and roles using the deployment guide. Apply migrations as a separate operator step before serving a new database:
diff --git a/openapi.json b/openapi.json
index bab1293..94dbdb1 100644
--- a/openapi.json
+++ b/openapi.json
@@ -5103,7 +5103,7 @@
"url": "https://github.com/fireflyframework/flycanon/blob/main/LICENSE"
},
"title": "flycanon",
- "version": "26.10.5"
+ "version": "26.10.6"
},
"openapi": "3.1.0",
"paths": {
diff --git a/pyproject.toml b/pyproject.toml
index ac2b5ae..4e032d1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@
name = "flycanon"
# CalVer YY.MM.PP -- bumped per release. Note that PEP 440 normalises
# ``26.05.04`` -> ``26.5.4`` in the built wheel filename.
-version = "26.10.5"
+version = "26.10.6"
description = "Operational Knowledge Repository -- versioned, provenance-tracked canonical knowledge with hybrid retrieval and RAG, exposed as a standalone HTTP microservice over fireflyframework-pyfly and -agentic. Part of Firefly Framework, platform-agnostic by design."
readme = "README.md"
requires-python = ">=3.13,<3.14"
diff --git a/sdks/java/README.md b/sdks/java/README.md
index 0a301aa..06c1ccf 100644
--- a/sdks/java/README.md
+++ b/sdks/java/README.md
@@ -41,16 +41,19 @@ Compatible with **flycanon service version `26.10.x`**.
| SDK | Service |
|-----|---------|
+| `26.10.6` | `26.10.x` (same request/response schemas; workspace creation requires matching scope) |
| `26.10.5` | `26.10.x` (same request/response schemas; workspace creation requires matching scope) |
| `26.5.7` | `26.5.x` |
+Nonempty source selectors are applied before BM25 and dense ranking by service `26.10.6` and later. The request schema and SDK search methods are unchanged; older compatible SDKs can use the corrected server.
+
## Install
```xml
com.firefly
flycanon-sdk
- 26.10.5
+ 26.10.6
```
@@ -418,7 +421,7 @@ use).
## Versioning
The SDK pins its version to the service's CalVer (`YY.MM.PP`), so
-`flycanon-sdk@26.10.5` is the matching client for service version
+`flycanon-sdk@26.10.6` is the matching client for service version
`26.10.x`. Upgrade the SDK in lockstep with the service. Both
clients (`CanonClient`, `ReactiveCanonClient`) ship from the same
artifact -- no separate dependency to manage.
diff --git a/sdks/java/pom.xml b/sdks/java/pom.xml
index f2f321c..8c81d2f 100644
--- a/sdks/java/pom.xml
+++ b/sdks/java/pom.xml
@@ -19,7 +19,7 @@
com.firefly
flycanon-sdk
- 26.10.5
+ 26.10.6
jar
flycanon-sdk
diff --git a/sdks/java/src/main/java/com/firefly/flycanon/sdk/CanonClient.java b/sdks/java/src/main/java/com/firefly/flycanon/sdk/CanonClient.java
index 2510a26..0e30036 100644
--- a/sdks/java/src/main/java/com/firefly/flycanon/sdk/CanonClient.java
+++ b/sdks/java/src/main/java/com/firefly/flycanon/sdk/CanonClient.java
@@ -94,7 +94,7 @@ public final class CanonClient {
static final String HEADER_IDEMPOTENCY_KEY = "Idempotency-Key";
/** SDK version. Mirrors {@code pom.xml}. */
- public static final String SDK_VERSION = "26.10.5";
+ public static final String SDK_VERSION = "26.10.6";
private final RestClient restClient;
private final ObjectMapper mapper;
diff --git a/sdks/python/README.md b/sdks/python/README.md
index 3029e8d..d1fb2ff 100644
--- a/sdks/python/README.md
+++ b/sdks/python/README.md
@@ -33,17 +33,20 @@ Compatible with **flycanon service version `26.10.x`**.
| SDK | Service |
|-----|---------|
+| `26.10.6` | `26.10.x` (same request/response schemas; workspace creation requires matching scope) |
| `26.10.5` | `26.10.x` (same request/response schemas; workspace creation requires matching scope) |
| `26.8.0` | `26.8.x` (embedding sets, `flycanon reindex`, `ChunkStats.by_embedding_model`) |
| `26.7.1` | `26.7.x` (routes realigned: `?mode=async`, `/query/stream`, `/conversations/{id}/turn`, `/query/suggest`, `?after_id=`; `X-API-Key`; `delete_source`, `purge_workspace`) |
| `26.5.7` | `26.5.x` |
+Nonempty source selectors are applied before BM25 and dense ranking by service `26.10.6` and later. The request schema and SDK search methods are unchanged; older compatible SDKs can use the corrected server.
+
## Install
```bash
-uv add https://github.com/fireflyframework/flycanon/releases/download/v26.10.5/flycanon_sdk-26.10.5-py3-none-any.whl
+uv add https://github.com/fireflyframework/flycanon/releases/download/v26.10.6/flycanon_sdk-26.10.6-py3-none-any.whl
# or
-pip install https://github.com/fireflyframework/flycanon/releases/download/v26.10.5/flycanon_sdk-26.10.5-py3-none-any.whl
+pip install https://github.com/fireflyframework/flycanon/releases/download/v26.10.6/flycanon_sdk-26.10.6-py3-none-any.whl
```
## Quick start
diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml
index dfcf84e..675131b 100644
--- a/sdks/python/pyproject.toml
+++ b/sdks/python/pyproject.toml
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
[project]
name = "flycanon-sdk"
-version = "26.10.5"
+version = "26.10.6"
description = "Async-first Python client for the flycanon Operational Knowledge Repository service."
readme = "README.md"
requires-python = ">=3.11"
diff --git a/sdks/python/src/flycanon_sdk/__init__.py b/sdks/python/src/flycanon_sdk/__init__.py
index 9597f09..a40d6a1 100644
--- a/sdks/python/src/flycanon_sdk/__init__.py
+++ b/sdks/python/src/flycanon_sdk/__init__.py
@@ -120,7 +120,7 @@
WorkspaceUpdated,
)
-__version__ = "26.10.5"
+__version__ = "26.10.6"
__all__ = [
"CANON_WORKSPACES_TOPIC",
diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py
index 70930a1..f8548d2 100644
--- a/sdks/python/tests/test_client.py
+++ b/sdks/python/tests/test_client.py
@@ -592,7 +592,7 @@ async def test_answer_returns_typed_response() -> None:
def test_sdk_version_declares_current_release() -> None:
- assert __version__ == "26.10.5"
+ assert __version__ == "26.10.6"
def test_canon_workspaces_topic_constant() -> None:
@@ -674,7 +674,7 @@ async def test_user_agent_advertises_sdk_version() -> None:
}
)
await client.version()
- assert "flycanon-sdk-python/26.10.5" in route.calls.last.request.headers["User-Agent"]
+ assert "flycanon-sdk-python/26.10.6" in route.calls.last.request.headers["User-Agent"]
# ----------------------------------------------------------------------
diff --git a/sdks/python/uv.lock b/sdks/python/uv.lock
index 4cfaa10..643d5cf 100644
--- a/sdks/python/uv.lock
+++ b/sdks/python/uv.lock
@@ -44,7 +44,7 @@ wheels = [
[[package]]
name = "flycanon-sdk"
-version = "26.10.5"
+version = "26.10.6"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
diff --git a/src/flycanon/core/services/retrieval/postgres_corpus.py b/src/flycanon/core/services/retrieval/postgres_corpus.py
index 3ab4a10..a208e0e 100644
--- a/src/flycanon/core/services/retrieval/postgres_corpus.py
+++ b/src/flycanon/core/services/retrieval/postgres_corpus.py
@@ -66,7 +66,7 @@
import asyncio
import logging
-from collections.abc import AsyncIterator
+from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from typing import Any
@@ -184,6 +184,7 @@ async def bm25_search(
top_k: int = 30,
tenant_id: str = "default",
workspace_id: str = "default",
+ source_ids: Sequence[str] | None = None,
) -> list[ChunkHit]:
"""Return the top-``top_k`` chunks ranked by Postgres BM25.
@@ -206,8 +207,9 @@ async def bm25_search(
q = (query or "").strip()
if not q:
return []
+ source_predicate = "" if source_ids is None else "AND c.source_id = ANY(CAST(:source_ids AS text[]))"
sql = text(
- """
+ f"""
SELECT
c.id AS chunk_id,
c.source_id AS doc_id,
@@ -221,6 +223,7 @@ async def bm25_search(
JOIN canon_sources AS s ON s.id = c.source_id
WHERE c.tenant_id = :tenant_id
AND c.workspace_id = :workspace_id
+ {source_predicate}
AND c.tsv @@ plainto_tsquery(:cfg, :q)
ORDER BY score DESC, c.created_at DESC
LIMIT :k
@@ -235,6 +238,7 @@ async def bm25_search(
"k": int(top_k),
"tenant_id": tenant_id,
"workspace_id": workspace_id,
+ **({"source_ids": list(source_ids)} if source_ids is not None else {}),
},
)
rows = result.mappings().all()
diff --git a/src/flycanon/core/services/retrieval/retrieval_service.py b/src/flycanon/core/services/retrieval/retrieval_service.py
index 78fe329..55945c5 100644
--- a/src/flycanon/core/services/retrieval/retrieval_service.py
+++ b/src/flycanon/core/services/retrieval/retrieval_service.py
@@ -14,15 +14,10 @@
"""Hybrid retrieval service.
-Wraps :class:`HybridRetriever`, hydrates the resulting
-:class:`ChunkHit` rows with Postgres metadata, and applies the
-caller-supplied filters (domain / jurisdiction / status / explicit
-ids) as a post-retrieval pass.
-
-Filters run AFTER retrieval because :class:`HybridRetriever` does not
-expose pre-filter predicates -- pulling more candidates and trimming
-locally is the strategy here. The ``per_query_k`` knob lets callers
-compensate by widening the retrieval window.
+Binds explicit source membership into both BM25 and dense reads before their
+ranked candidate windows. Hydration still applies every caller filter as a
+defence-in-depth pass; knowledge status, domain and jurisdiction continue to
+use their hydrated metadata.
"""
from __future__ import annotations
@@ -34,6 +29,8 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
+from fireflyframework_agentic.vectorstores.types import SearchFilter
+
from flycanon.core.services.embeddings import (
EmbeddingRegistry,
EmbeddingService,
@@ -155,6 +152,8 @@ async def search(
"callers must thread the tenant scope explicitly."
)
+ source_ids = tuple(filters.source_ids) if filters is not None and filters.source_ids else None
+
from flycanon.core.services.retrieval.fusion import HybridRetriever
# Which embedding space this workspace's corpus lives in, and the
@@ -174,12 +173,14 @@ async def search(
self._context.corpus,
tenant_id=tenant_id,
workspace_id=workspace_id,
+ source_ids=source_ids,
)
scoped_vector_store = _ScopedVectorStore(
self._context.vector_store,
tenant_id=tenant_id,
workspace_id=workspace_id,
enabled=binding is not None or self._embedding_sets is None,
+ source_ids=source_ids,
)
# The embedder protocol is honoured by EmbeddingService
# via the ``embed`` method; pass it through directly.
@@ -489,10 +490,13 @@ class _ScopedCorpus:
through :class:`IndexService` directly, with its own scope threading.
"""
- def __init__(self, inner: object, *, tenant_id: str, workspace_id: str) -> None:
+ def __init__(
+ self, inner: object, *, tenant_id: str, workspace_id: str, source_ids: Sequence[str] | None = None
+ ) -> None:
self._inner = inner
self._tenant_id = tenant_id
self._workspace_id = workspace_id
+ self._source_ids = source_ids
async def bm25_search(self, query: str, *, top_k: int = 30): # noqa: ANN201
return await self._inner.bm25_search( # type: ignore[attr-defined]
@@ -500,6 +504,7 @@ async def bm25_search(self, query: str, *, top_k: int = 30): # noqa: ANN201
top_k=top_k,
tenant_id=self._tenant_id,
workspace_id=self._workspace_id,
+ **({"source_ids": self._source_ids} if self._source_ids is not None else {}),
)
async def get_chunks(self, chunk_ids: list[str]): # noqa: ANN201
@@ -524,10 +529,19 @@ class _ScopedVectorStore:
foreign-scope chunk is dropped when hydration can't find it.
"""
- def __init__(self, inner: object, *, tenant_id: str, workspace_id: str, enabled: bool = True) -> None:
+ def __init__(
+ self,
+ inner: object,
+ *,
+ tenant_id: str,
+ workspace_id: str,
+ enabled: bool = True,
+ source_ids: Sequence[str] | None = None,
+ ) -> None:
self._inner = inner
self._tenant_id = tenant_id
self._workspace_id = workspace_id
+ self._source_ids = source_ids
# ``False`` when the workspace has no embedding set: there are no
# vectors to find, so the dense half returns nothing rather than
# querying one embedding space with a vector from another. BM25 still
@@ -542,4 +556,9 @@ async def search(self, query_embedding, top_k: int = 5): # noqa: ANN001, ANN201
top_k=top_k,
tenant_id=self._tenant_id,
workspace_id=self._workspace_id,
+ **(
+ {"filters": [SearchFilter(field="source_id", operator="in", value=list(self._source_ids))]}
+ if self._source_ids is not None
+ else {}
+ ),
)
diff --git a/tests/unit/test_retrieval_source_preranking.py b/tests/unit/test_retrieval_source_preranking.py
new file mode 100644
index 0000000..53d19c9
--- /dev/null
+++ b/tests/unit/test_retrieval_source_preranking.py
@@ -0,0 +1,177 @@
+# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0
+"""Admitted source membership precedes each ranked candidate window."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from flycanon.core.services.retrieval.corpus_factory import CorpusContext
+from flycanon.core.services.retrieval.fusion import ChunkHit, StoredChunk
+from flycanon.core.services.retrieval.retrieval_service import Hit, RetrievalFilters, RetrievalService
+
+
+class RankedCorpus:
+ def __init__(self, rows, enabled=True):
+ self.rows = rows
+ self.enabled = enabled
+ self.reads = 0
+
+ async def bm25_search(self, query, *, top_k, source_ids=None, **scope):
+ self.reads += 1
+ rows = [
+ r
+ for r in self.rows
+ if r.metadata.get("tenant_id", "forge") == scope["tenant_id"]
+ and r.metadata.get("workspace_id", "current") == scope["workspace_id"]
+ and (source_ids is None or r.doc_id in source_ids)
+ ]
+ return (
+ [ChunkHit(chunk_id=r.chunk_id, doc_id=r.doc_id, content=r.content, score=1) for r in rows[:top_k]]
+ if self.enabled
+ else []
+ )
+
+ async def get_chunks(self, ids, **scope):
+ return [r for r in self.rows if r.chunk_id in ids]
+
+
+class RankedVectors:
+ def __init__(self, rows, enabled=True):
+ self.rows = rows
+ self.enabled = enabled
+ self.reads = 0
+
+ async def search(self, vector, *, top_k, filters=None, **scope):
+ self.reads += 1
+ rows = [
+ r
+ for r in self.rows
+ if r.metadata.get("tenant_id", "forge") == scope["tenant_id"]
+ and r.metadata.get("workspace_id", "current") == scope["workspace_id"]
+ ]
+ for f in filters or []:
+ assert f.field == "source_id" and f.operator == "in"
+ rows = [r for r in rows if r.doc_id in f.value]
+ return (
+ [SimpleNamespace(document=SimpleNamespace(id=r.chunk_id)) for r in rows[:top_k]]
+ if self.enabled
+ else []
+ )
+
+
+def service(channel):
+ rows = [
+ StoredChunk(
+ "foreign",
+ "admitted",
+ "foreign",
+ 0,
+ "foreign admitted source",
+ metadata={"tenant_id": "other", "workspace_id": "foreign"},
+ )
+ ]
+ rows += [
+ StoredChunk(str(i), "outside-" + str(i), "other", 0, "distracting higher ranked content")
+ for i in range(60)
+ ]
+ rows += [StoredChunk("wanted-chunk", "admitted", "current", 0, "official admitted API content")]
+ corpus = RankedCorpus(rows, channel != "vector")
+ vectors = RankedVectors(rows, channel != "bm25")
+ embeddings = MagicMock()
+ embeddings.embed_one = AsyncMock(return_value=[0.1, 0.2, 0.3])
+ svc = RetrievalService(
+ context=CorpusContext(corpus=corpus, vector_store=vectors, backend="test"),
+ embeddings=embeddings,
+ source_repository=MagicMock(),
+ chunk_repository=MagicMock(),
+ knowledge_repository=MagicMock(),
+ default_top_k=5,
+ default_per_query_k=20,
+ rrf_k=60,
+ )
+
+ async def hydrate(hits):
+ return [
+ Hit(
+ chunk_id=h.chunk_id,
+ source_id=h.doc_id,
+ content=h.content,
+ score=h.score,
+ knowledge_item_id=None,
+ knowledge_version=None,
+ bm25_rank=None,
+ vector_rank=None,
+ metadata={},
+ )
+ for h in hits
+ ]
+
+ svc._hydrate = hydrate
+ return svc, corpus, vectors
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("channel", ["bm25", "vector", "hybrid"])
+async def test_source_filter_precedes_bounded_ranking_in_each_channel(channel):
+ svc, _, _ = service(channel)
+ result = await svc.search(
+ query="official API",
+ tenant_id="forge",
+ workspace_id="current",
+ top_k=1,
+ per_query_k=2,
+ filters=RetrievalFilters(source_ids=["admitted"]),
+ )
+ assert [h.source_id for h in result.hits] == ["admitted"]
+ assert result.hits[0].content == "official admitted API content"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("selector", [None, []])
+async def test_none_and_empty_list_preserve_unfiltered_source_selector_compatibility(selector):
+ svc, corpus, vectors = service("hybrid")
+ result = await svc.search(
+ query="official API",
+ tenant_id="forge",
+ workspace_id="current",
+ top_k=1,
+ filters=RetrievalFilters(source_ids=selector),
+ )
+ assert [h.source_id for h in result.hits] == ["outside-0"]
+ assert corpus.reads == vectors.reads == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("channel", ["bm25", "vector", "hybrid"])
+async def test_nonempty_selector_with_no_chunks_does_not_fall_back_to_global_candidates(channel):
+ svc, _, _ = service(channel)
+ result = await svc.search(
+ query="official API",
+ tenant_id="forge",
+ workspace_id="current",
+ filters=RetrievalFilters(source_ids=["has-no-chunks"]),
+ )
+ assert result.hits == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("tenant", "workspace", "content"),
+ [
+ ("forge", "current", "official admitted API content"),
+ ("other", "foreign", "foreign admitted source"),
+ ("forge", "foreign", None),
+ ],
+)
+async def test_source_membership_intersects_tenant_and_workspace_before_ranking(tenant, workspace, content):
+ svc, _, _ = service("hybrid")
+ result = await svc.search(
+ query="official API",
+ tenant_id=tenant,
+ workspace_id=workspace,
+ top_k=1,
+ per_query_k=2,
+ filters=RetrievalFilters(source_ids=["admitted"]),
+ )
+ assert [h.content for h in result.hits] == ([] if content is None else [content])
diff --git a/uv.lock b/uv.lock
index 542837e..71852ed 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1420,7 +1420,7 @@ wheels = [
[[package]]
name = "flycanon"
-version = "26.10.5"
+version = "26.10.6"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },