diff --git a/TRACKER.md b/TRACKER.md
index f9a6ff9..ab24740 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -14,12 +14,13 @@
| | |
|---|---|
| **Last updated** | 2026-06-08 |
-| **Current phase** | Phase 6 — Governance & Tenancy (**1 / 10 steps**) |
-| **Overall** | **65 / 84 steps** — Phases 0–5 complete |
-| **Next action** | **Step 6.2 — Physical tenancy (dedicated index)**: dedicated vector index per tenant; cross-tenant probe gate. Builds on the 6.1 namespace primitive. |
+| **Current phase** | Phase 6 — Governance & Tenancy (**2 / 10 steps**) |
+| **Overall** | **66 / 84 steps** — Phases 0–5 complete |
+| **Next action** | **Step 6.3 — ACL push-down at retrieval**: inject the ACL filter into every vector / BM25 / graph query; `acl.egress_denied` event. Consumes the per-tenant `acl_labels` resolved in 6.1. |
**Recently shipped**
+- **6.2** ✅ Physical tenancy — per-tenant *dedicated* vector index/collection; `TenantConfig.dedicated_index` → `ctx.physical_index` → backend `-` (Noop/Pinecone/Qdrant); cross-tenant probe gate — [#149](https://github.com/officialCodeWork/AgentContextOS/pull/149)
- **6.1** ✅ Logical multi-tenancy — `TenantResolver` resolves per-tenant `rag.yaml` config (namespace / PII / ACL labels) → `TenantSettings` applied at the gateway boundary; `RequestContext.namespace`; `GET /v1/status/tenant`; `ragctl tenant` — [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148)
- **5.7d** ✅ Experiments console card + Phase-5 close-out — admin Live-Status A/B card over `GET /v1/status/experiments` (lift + CI per experiment) — [#147](https://github.com/officialCodeWork/AgentContextOS/pull/147)
- **5.7c** ✅ A/B routing — `ABRouter` deterministically *serves* the candidate to a fraction of users (variant-partitioned cache, `ExperimentAssignment` response tag) — [#146](https://github.com/officialCodeWork/AgentContextOS/pull/146)
@@ -55,9 +56,9 @@
| 3 | Gateway & Agent Runtime | 11 | **11** | 0 |
| 4 | Reliability | 6 | **6** | 0 |
| 5 | Eval & Observability | 7 | **7** | 0 |
-| 6 | Governance & Tenancy | 10 | **1** | 9 |
+| 6 | Governance & Tenancy | 10 | **2** | 8 |
| 7 | Pilot, Harden, GA | 10 | 0 | 10 |
-| **Total** | | **84** | **65** | **19** |
+| **Total** | | **84** | **66** | **18** |
---
@@ -640,12 +641,12 @@
- **Phase-5 close-out:** Step 5.7 ✅ → **Phase 5 complete (7 / 7)**; deferred items remain documented (per-tenant drift / per-dimension embedding PSI; feedback/breaker/quota Grafana export + Loki-events dashboard; gRPC proto mirror of `corpus_decision` + `experiment`; sequential / multi-metric experiments)
- [reference/experiments.md](docs/reference/experiments.md), [reference/admin-ui.md](docs/reference/admin-ui.md)
-## Phase 6 — Governance & Tenancy (Weeks 28–34) 🚧 (1 / 10)
+## Phase 6 — Governance & Tenancy (Weeks 28–34) 🚧 (2 / 10)
| Step | Title | Status | Planned deliverables |
|------|-------|:------:|----------------------|
| 6.1 | Logical multi-tenancy | ✅ | [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148) — `TenantResolver` → `TenantSettings` (namespace / PII / ACL labels) applied at the boundary; `RequestContext.namespace`; `GET /v1/status/tenant`; `ragctl tenant` |
-| 6.2 | Physical tenancy (dedicated index) | ⏳ | Dedicated vector index per tenant; cross-tenant probe gate |
+| 6.2 | Physical tenancy (dedicated index) | ✅ | [#149](https://github.com/officialCodeWork/AgentContextOS/pull/149) — `dedicated_index` → `ctx.physical_index` → backend `-` (Noop/Pinecone/Qdrant, lazy create); cross-tenant probe gate |
| 6.3 | ACL push-down at retrieval | ⏳ | ACL filter injected into every vector / BM25 / graph query; `acl.egress_denied` event |
| 6.4 | ACL egress verifier | ⏳ | Post-retrieval re-check; defense-in-depth; zero-ACL-violation-rate gate |
| 6.5 | PII policies | ⏳ | Per-tenant PII enforcement (block / redact / allow); egress redaction; `pii.egress_blocked` event |
@@ -665,6 +666,15 @@
- **Scope:** resolution + threading only — ACL push-down enforcement is 6.3, PII egress is 6.5, physical tenancy is 6.2; ~21 new tests (resolver, config, gateway, cross-tenant red-team, CLI); all gates green (ruff, mypy --strict 293 files, RAG001, schema/openapi-drift, proto-compat, policy-coverage)
- [ADR-0033](docs/adr/ADR-0033-logical-multi-tenancy.md), [reference/tenancy.md](docs/reference/tenancy.md), [architecture/multi-tenancy.md](docs/architecture/multi-tenancy.md)
+### 6.2 — Physical tenancy (dedicated index) ✅ [#149](https://github.com/officialCodeWork/AgentContextOS/pull/149)
+
+- *Logical* tenancy (6.1) shares one index + a `tenant_id` filter; **physical** tenancy gives a tenant its **own** vector index/collection, so its data is separate even if a filter were bypassed. Opt-in per tenant via **`TenantConfig.dedicated_index`** (+ optional `dedicated_index_name`)
+- Same resolve-once-at-the-boundary pattern as 6.1: the resolver computes a **`physical_index`** key (`dedicated_index_name or tenant_id`, else `None`) on `TenantSettings`, threaded onto new **`RequestContext.physical_index`**; the gateway middleware applies it
+- **Backends read only `ctx`** (graph is backends→core, never rag-config), so the decision arrives via `ctx.physical_index`; each namespaces its base under the key (**`-`**) and **lazily creates** the dedicated index/collection on first use, falling back to the base index when `None`. Implemented for **Noop** (CI oracle, partitioned by `physical_index`) + **Pinecone** (dedicated index) + **Qdrant** (dedicated collection); pgvector/ES/Weaviate follow later
+- **Cross-tenant probe gate** (`tests/redteam/test_cross_tenant_dedicated_index.py`, CI, Noop) proves a dedicated tenant's data is invisible to another **independent of the tenant filter** (a matching `tenant_id` pointed at a different index still returns nothing); live backends are integration-tested
+- One instance + per-tenant derivation (**no** per-tenant backend instances, **no** SPI change); `GET /v1/status/tenant` + `ragctl tenant resolve` report `dedicated_index` / `physical_index`; `TenantConfig` fields → `rag.schema`, `TenantSettings` + `RequestContext` regenerated, `/v1/status/tenant` → `dist/openapi`; ~30 new/updated tests; all gates green
+- [ADR-0034](docs/adr/ADR-0034-physical-multi-tenancy.md), [reference/tenancy.md](docs/reference/tenancy.md#physical-tenancy-step-62), [architecture/multi-tenancy.md](docs/architecture/multi-tenancy.md)
+
---
## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳
@@ -817,6 +827,7 @@ Complete log of every PR. Routine Dependabot bumps are grouped; everything else
| [#146](https://github.com/officialCodeWork/AgentContextOS/pull/146) | 2026-06-07 | feat(experiments): A/B routing — serve the candidate to a fraction of users (Step 5.7c) |
| [#147](https://github.com/officialCodeWork/AgentContextOS/pull/147) | 2026-06-07 | feat(admin-ui): A/B experiments console card + Phase-5 close-out (Step 5.7d) |
| [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148) | 2026-06-08 | feat(tenancy): logical multi-tenancy — per-tenant config resolution (Step 6.1) |
+| [#149](https://github.com/officialCodeWork/AgentContextOS/pull/149) | 2026-06-08 | feat(tenancy): physical tenancy — dedicated index per tenant (Step 6.2) |
| #78–#80, #116–#118 | Open | Dependabot bumps — awaiting merge |
| #81 | Closed | Dependabot bump — superseded |
diff --git a/apps/gateway/src/rag_gateway/middleware.py b/apps/gateway/src/rag_gateway/middleware.py
index 3b625b9..b5e4937 100644
--- a/apps/gateway/src/rag_gateway/middleware.py
+++ b/apps/gateway/src/rag_gateway/middleware.py
@@ -167,10 +167,12 @@ async def build_gateway_context(
if principal is not None and tenant_id is not None:
namespace = str(tenant_id)
pii_policy = None
+ physical_index: str | None = None
if tenant_resolver is not None:
tenant_settings = tenant_resolver.resolve(str(tenant_id))
namespace = tenant_settings.namespace
pii_policy = tenant_settings.pii_policy
+ physical_index = tenant_settings.physical_index
if tenant_settings.acl_labels:
principal = principal.model_copy(
update={"acl_labels": principal.acl_labels | tenant_settings.acl_labels}
@@ -180,6 +182,7 @@ async def build_gateway_context(
tenant_id=tenant_id,
principal=principal,
namespace=namespace,
+ physical_index=physical_index,
**({"pii_policy": pii_policy} if pii_policy is not None else {}),
trace=trace,
)
diff --git a/apps/gateway/src/rag_gateway/status.py b/apps/gateway/src/rag_gateway/status.py
index 62ce603..b2aefa5 100644
--- a/apps/gateway/src/rag_gateway/status.py
+++ b/apps/gateway/src/rag_gateway/status.py
@@ -332,6 +332,10 @@ class TenantStatusResponse(BaseModel):
namespace: str = ""
pii_action: str = "redact"
acl_labels: list[str] = Field(default_factory=list)
+ # Physical tenancy (Step 6.2): whether this tenant has a dedicated index, and
+ # its key (``None`` when it shares the base index).
+ dedicated_index: bool = False
+ physical_index: str | None = None
class ExperimentsStatusResponse(BaseModel):
@@ -898,6 +902,8 @@ async def status_tenant(request: Request, tenant_id: str | None = None) -> Tenan
namespace=settings.namespace,
pii_action=settings.pii_policy.action.value,
acl_labels=sorted(settings.acl_labels),
+ dedicated_index=settings.physical_index is not None,
+ physical_index=settings.physical_index,
)
@router.websocket("/v1/status/ws")
diff --git a/apps/gateway/tests/test_tenancy.py b/apps/gateway/tests/test_tenancy.py
index ec8130b..7f8401f 100644
--- a/apps/gateway/tests/test_tenancy.py
+++ b/apps/gateway/tests/test_tenancy.py
@@ -100,9 +100,22 @@ def test_status_tenant_known() -> None:
"namespace": "acme-prod",
"pii_action": "block",
"acl_labels": ["region:eu"],
+ "dedicated_index": False,
+ "physical_index": None,
}
+def test_status_tenant_reports_dedicated_index() -> None:
+ cfg = {
+ "version": "1",
+ "tenants": [{"id": "vault", "name": "Vault", "dedicated_index": True}],
+ }
+ client = TestClient(build_app_from_config(RagConfig.model_validate(cfg)))
+ body = client.get("/v1/status/tenant", headers={"X-Tenant-Id": "vault"}).json()
+ assert body["dedicated_index"] is True
+ assert body["physical_index"] == "vault" # auto-derived from the id
+
+
def test_status_tenant_unknown_is_defaulted() -> None:
client = TestClient(build_app_from_config(RagConfig.model_validate(_resolver_cfg())))
body = client.get("/v1/status/tenant", headers={"X-Tenant-Id": "ghost"}).json()
diff --git a/dist/openapi.json b/dist/openapi.json
index 39563b5..d034717 100644
--- a/dist/openapi.json
+++ b/dist/openapi.json
@@ -3674,6 +3674,11 @@
"title": "Acl Labels",
"type": "array"
},
+ "dedicated_index": {
+ "default": false,
+ "title": "Dedicated Index",
+ "type": "boolean"
+ },
"known": {
"default": false,
"title": "Known",
@@ -3684,6 +3689,17 @@
"title": "Namespace",
"type": "string"
},
+ "physical_index": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Physical Index"
+ },
"pii_action": {
"default": "redact",
"title": "Pii Action",
diff --git a/dist/openapi.yaml b/dist/openapi.yaml
index 94eec7f..6aebdf4 100644
--- a/dist/openapi.yaml
+++ b/dist/openapi.yaml
@@ -3255,6 +3255,10 @@ components:
type: string
title: Acl Labels
type: array
+ dedicated_index:
+ default: false
+ title: Dedicated Index
+ type: boolean
known:
default: false
title: Known
@@ -3263,6 +3267,11 @@ components:
default: ''
title: Namespace
type: string
+ physical_index:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Physical Index
pii_action:
default: redact
title: Pii Action
diff --git a/dist/rag.schema.json b/dist/rag.schema.json
index e40ad74..98896e3 100644
--- a/dist/rag.schema.json
+++ b/dist/rag.schema.json
@@ -1325,6 +1325,24 @@
},
"title": "Acl Labels",
"type": "array"
+ },
+ "dedicated_index": {
+ "default": false,
+ "title": "Dedicated Index",
+ "type": "boolean"
+ },
+ "dedicated_index_name": {
+ "anyOf": [
+ {
+ "minLength": 1,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Dedicated Index Name"
}
},
"required": [
diff --git a/dist/rag.schema.yaml b/dist/rag.schema.yaml
index 989a042..b3ace92 100644
--- a/dist/rag.schema.yaml
+++ b/dist/rag.schema.yaml
@@ -1231,6 +1231,17 @@ $defs:
type: string
title: Acl Labels
type: array
+ dedicated_index:
+ default: false
+ title: Dedicated Index
+ type: boolean
+ dedicated_index_name:
+ anyOf:
+ - minLength: 1
+ type: string
+ - type: 'null'
+ default: null
+ title: Dedicated Index Name
required:
- id
- name
diff --git a/dist/schemas/RequestContext.json b/dist/schemas/RequestContext.json
index 0f0292b..18579f2 100644
--- a/dist/schemas/RequestContext.json
+++ b/dist/schemas/RequestContext.json
@@ -206,6 +206,18 @@
"title": "Namespace",
"type": "string"
},
+ "physical_index": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Physical Index"
+ },
"pii_policy": {
"$ref": "#/$defs/PiiPolicy"
},
diff --git a/dist/schemas/TenantSettings.json b/dist/schemas/TenantSettings.json
index a92ebea..55dc760 100644
--- a/dist/schemas/TenantSettings.json
+++ b/dist/schemas/TenantSettings.json
@@ -58,6 +58,18 @@
"title": "Acl Labels",
"type": "array",
"uniqueItems": true
+ },
+ "physical_index": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Physical Index"
}
},
"required": [
diff --git a/docs/README.md b/docs/README.md
index 0bc34c4..e37f465 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -172,6 +172,7 @@ broken, and what to fix before committing to the next phase.
| [ADR-0027-offline-eval-harness.md](adr/ADR-0027-offline-eval-harness.md) | Decision (Step 5.2): complete the Step 0.8 eval skeleton with a deterministic, offline harness — synthetic 5-domain corpus on the noop SPIs (a `HashingEmbedder` gives the zero-vector dense path real signal, fused with `NoopKeywordStore` token overlap via the real `HybridRetriever`); 500-query golden set committed under `tests/eval/golden/` and generated reproducibly from the corpus (drift-gated), 3 passages/concept so nDCG + Citation Precision aren't degenerate; nDCG added + dependency-free `lexical_faithfulness` default (RAGAS optional) keep CI ML-free; harness in `eval/golden_set_v0/` (not `rag_config`) so config stays light; additive report fields + self-contained HTML; `--check` enforces the threshold floor now (regression-delta is 5.3); rejected real backends in CI, RAGAS-as-default, hand-authored queries |
| [ADR-0032-ab-testing-shadow-mode.md](adr/ADR-0032-ab-testing-shadow-mode.md) | Decision (Step 5.7): compare two configs on live traffic, delivered in slices (5.7a analyzer+tracker+surface, 5.7b shadow, 5.7c routing, 5.7d console); the analyzer is pure + stdlib-only in `rag_config.eval` (normal-approx Welch via `statistics.NormalDist`, no numpy/scipy — same spirit as drift PSI / cost z-score); the `ABExperimentTracker` is a pure *sample holder* in `rag_observability` (so it doesn't import `rag_config`), and the gateway composes the two for `GET /v1/status/experiments`; opt-in by default (A/B routing can change responses); `ABAnalysisResult` additive in `rag_core.eval` (not in `dist/schemas`); rejected numpy/scipy, a new package, putting the analyzer in observability, defaulting on |
| [ADR-0033-logical-multi-tenancy.md](adr/ADR-0033-logical-multi-tenancy.md) | Decision (Step 6.1): make per-tenant `rag.yaml` config drive requests. `TenantResolver` (`rag_config`) maps a tenant id → frozen `TenantSettings` (`rag_core`), applied once at the gateway boundary; resolver in config / settings type in core keeps the `config → core` direction; unknown tenants → safe defaults (isolated not privileged); `RequestContext.namespace` defaults to `tenant_id` (a backend-partition primitive — Pinecone uses it — not a chunk field, so `filter_pushdown` is unchanged); scope stops at resolution + threading (ACL push-down 6.3, PII egress 6.5, physical tenancy 6.2); additive + inert in `build_app` |
+| [ADR-0034-physical-multi-tenancy.md](adr/ADR-0034-physical-multi-tenancy.md) | Decision (Step 6.2): a *dedicated* vector index/collection per tenant. `TenantConfig.dedicated_index` resolves to a `physical_index` key on `TenantSettings`, threaded onto `RequestContext.physical_index`; backends read only `ctx` (graph is backends→core, never rag-config) and namespace their base under it (`-`), lazily creating it; one instance + per-tenant derivation (no per-tenant instances, no SPI change); Noop is the CI conformance oracle (keyed by `physical_index`) for a cross-tenant probe gate that proves isolation independent of the tenant filter; Noop + Pinecone + Qdrant this step, others later |
| [ADR-0031-cost-anomaly.md](adr/ADR-0031-cost-anomaly.md) | Decision (Step 5.6c): detect per-tenant spend spikes with a rolling `CostTracker` (not the cumulative quota counter); detect scale-free on the token series (cost = tokens × a constant price) so detection is decoupled from quota pricing and works with quotas off; two gates (ratio + z-score, z relaxed on a flat baseline) → tri-state verdict; put it in `rag-observability` as a `dataclass` (gateway wraps it in a Pydantic `CostStatusResponse`) so there's **no `rag-core` type / `dist/schemas` churn**; feed O(1) from `record_request_usage` before the quota block; pull-based `GET /v1/status/cost` (no per-request span/event); rejected folding into the infra-scoped drift registry, a new package, a `cost.anomaly_detected` push event (deferred), per-model pricing, a time-series DB |
| [ADR-0030-drift-monitors.md](adr/ADR-0030-drift-monitors.md) | Decision (Step 5.5): detect retrieval degradation with five drift monitors in a new `rag-drift` package (mirroring rag-feedback); two statistics — PSI (pure, binned, dependency-free) for the distribution monitors + mean-drop for the rate/score monitors — over one scalar-window `DriftMonitor`; infra-scoped registry (like breakers) fed via `observe` from the signals the gateway already computes (query length / retrieval score / HyDE-embedding norm / guard grounded-claim fraction / feedback citation clicks); detection on dashboard-poll `evaluate()` with transition-edge `drift.detected` (structured event + the Step 3.9 webhook, targeting `alert_tenant`); observe-only / inert-by-default / rebaseline; rejected per-tenant monitors, per-dimension embedding PSI, a stats library, a background scheduler, hot-path detection |
| [ADR-0029-online-feedback.md](adr/ADR-0029-online-feedback.md) | Decision (Step 5.4): capture online feedback + implicit signals in a new `rag-feedback` package mirroring `rag-provenance` (SPI + types in rag-core; recorder + pure aggregator in the package); one polymorphic `POST /v1/feedback` (a `signal` enum spanning explicit thumbs/rating/comment + implicit citation-click/copy/regenerate/dwell, `kind` inferred); normalise every signal to a `[-1,1]` score so the dashboard has one satisfaction number; **redact-don't-hash** free-text comments via an injected `PIIDetector` (default `NoopPIIDetector` seam, `comment_redacted` flag, PII-free event) — opposite of provenance's hashing; body identity like `/v1/query`; degrade-open + inert-by-default; `GET /v1/status/feedback` dashboard (event-only, no per-call span); admin-UI card deferred to 5.6; rejected separate per-signal endpoints, header-auth, hashing/raw comments, an OTel span per submission, folding into provenance |
diff --git a/docs/adr/ADR-0034-physical-multi-tenancy.md b/docs/adr/ADR-0034-physical-multi-tenancy.md
new file mode 100644
index 0000000..6ca86e3
--- /dev/null
+++ b/docs/adr/ADR-0034-physical-multi-tenancy.md
@@ -0,0 +1,71 @@
+# ADR-0034 — Physical multi-tenancy (dedicated index)
+
+**Status:** Accepted
+**Date:** 2026-06-08
+**Step:** 6.2 — Physical tenancy (dedicated index) (Phase 6 — Governance & Tenancy)
+**Related:** [ADR-0033](ADR-0033-logical-multi-tenancy.md) (logical multi-tenancy), [reference/tenancy.md](../reference/tenancy.md), [architecture/multi-tenancy.md](../architecture/multi-tenancy.md)
+
+## Context
+
+Step 6.1 delivered *logical* multi-tenancy: one shared vector index, isolated by a
+`tenant_id` filter (+ the `ctx.namespace` partition). For tenants that require hard
+isolation — regulatory, contractual, or blast-radius — a filter is not enough; they
+need their data in a **physically separate** index/collection, so a bug or a bypassed
+filter still can't surface another tenant's vectors. That is Step 6.2.
+
+## Decision
+
+**1. A resolved `physical_index` key, threaded on the `RequestContext`.** Mirroring how
+6.1 threaded `namespace`: `TenantConfig.dedicated_index` (bool) + optional
+`dedicated_index_name` resolve to a `physical_index` key on `TenantSettings`
+(`dedicated_index_name or tenant_id`, else `None`), applied once at the gateway boundary
+onto `RequestContext.physical_index`. A logical-only tenant resolves to `None`.
+
+**2. Backends derive `-`; they read only `ctx`.** `rag-backends` may import
+`rag-core` only — never `rag-config`/`TenantResolver` — so the per-tenant decision must
+arrive via `ctx`. Each backend namespaces its own base index under the key
+(`-`) and falls back to the base index when `None`. This keeps the
+naming uniform and collision-safe (everything under the deployment's base) and needs **no
+SPI change** — the methods already take `ctx`.
+
+**3. One backend instance, per-tenant index derivation — not per-tenant instances.** The
+single shared backend selects/creates the per-tenant index from `ctx`. Per-tenant backend
+*instances* (and their memory/wiring cost) are deferred; callers still see one
+`HybridRetriever`.
+
+**4. Lazy check-and-create.** A dedicated index/collection is created on first use and
+cached (Pinecone index handle / Qdrant ensured-set), reusing the dimension from
+`initialize()`. No eager provisioning step is required for correctness; an operator can
+still pre-create out of band.
+
+**5. The Noop store is the CI conformance oracle.** Real per-index isolation lives in
+Pinecone/Qdrant (live-service integration tests, skipped in CI). To get a *CI-runnable*
+cross-tenant probe gate, `NoopVectorStore` partitions its in-memory store by
+`ctx.physical_index`, so a read only ever scans its own partition. The probe gate asserts
+isolation **independent of the tenant filter** (a matching `tenant_id` pointed at a
+different index still returns nothing).
+
+**6. Backends in this step: Noop + Pinecone + Qdrant.** Pinecone (dedicated index) and
+Qdrant (dedicated collection) are the cleanest native mappings; pgvector / Elasticsearch /
+Weaviate follow the same `-` pattern in later PRs.
+
+## Consequences
+
+**Positive**
+- Hard isolation for tenants that need it, opt-in per tenant in `rag.yaml`, with the same
+ resolve-once-at-the-boundary discipline as 6.1.
+- No SPI change, no per-tenant instances, no `rag-config` dependency in backends.
+- The probe gate runs in CI on the Noop oracle and proves physical (not just filter)
+ separation; live backends are integration-tested.
+
+**Negative / deferred**
+- The backend now knows how to derive a per-tenant index (a small multi-tenancy concern in
+ the impl) — accepted because `ctx` is already threaded everywhere.
+- pgvector / Elasticsearch / Weaviate dedicated indexes; per-tenant backend instances;
+ per-tenant *provider* selection; and data migration shared→dedicated are all deferred.
+- Lazy creation makes a tenant's first request slightly slower; an operator can pre-create
+ to avoid it.
+
+## See also
+- [reference/tenancy.md](../reference/tenancy.md) — config + `physical_index` + probe gate
+- [architecture/multi-tenancy.md](../architecture/multi-tenancy.md) — design + boundaries
diff --git a/docs/architecture/multi-tenancy.md b/docs/architecture/multi-tenancy.md
index 601c35e..ce61e4c 100644
--- a/docs/architecture/multi-tenancy.md
+++ b/docs/architecture/multi-tenancy.md
@@ -50,22 +50,35 @@ request (X-Tenant-Id: acme)
## Governance & boundaries
-This is the *foundation* for Phase 6; it resolves and threads the primitives that
-later steps enforce:
+Step 6.1 is the *foundation* for Phase 6; it resolves and threads the primitives
+that later steps enforce:
-| Concern | Step | This step's part |
-|---------|------|------------------|
-| Per-tenant config / namespace / ACL+PII *model* | **6.1** | resolve + thread |
-| Physical tenancy (dedicated index) | 6.2 | — |
+| Concern | Step | Status |
+|---------|------|--------|
+| Per-tenant config / namespace / ACL+PII *model* | **6.1** | resolve + thread ✅ |
+| Physical tenancy (dedicated index) | **6.2** | resolved `physical_index` → backend `-` ✅ |
| ACL push-down at retrieval | 6.3 | labels are on the principal, ready to inject |
| ACL egress verifier | 6.4 | — |
| PII egress enforcement | 6.5 | `ctx.pii_policy` is resolved, ready to enforce |
+## Physical tenancy (Step 6.2)
+
+The same resolve-once-at-the-boundary pattern extends to *physical* isolation. A
+tenant with `dedicated_index: true` resolves to a `physical_index` key on
+`TenantSettings`, threaded onto `RequestContext.physical_index`. A vector backend —
+reading only `ctx` (backends import `rag-core`, never `rag-config`) — namespaces its
+base index under the key (`-`) and creates it lazily on first use; `None`
+keeps the shared base index. Supported on Noop (the CI oracle, keyed by
+`physical_index`), Pinecone (dedicated index), and Qdrant (dedicated collection). The
+cross-tenant probe gate proves isolation *independent of the tenant filter* on the
+Noop oracle; live backends are integration-tested. See
+[ADR-0034](../adr/ADR-0034-physical-multi-tenancy.md).
+
## Observability
- `GET /v1/status/tenant` returns the resolved view for a tenant (namespace, PII
- action, ACL labels, and whether it's `known` in config) — so an operator can see
- exactly what config a tenant's requests run under.
+ action, ACL labels, `dedicated_index` / `physical_index`, and whether it's `known`
+ in config) — so an operator can see exactly what config a tenant runs under.
- `ragctl tenant resolve ` prints the same resolution against a `rag.yaml`.
## Inert by default
diff --git a/docs/architecture/request-context.md b/docs/architecture/request-context.md
index b273c17..b4731c5 100644
--- a/docs/architecture/request-context.md
+++ b/docs/architecture/request-context.md
@@ -29,6 +29,7 @@ class RequestContext(BaseModel):
tenant_id: TenantId # required; matched against principal.tenant_id
principal: Principal # user/service identity + ACL labels
namespace: str # logical-isolation key (Step 6.1); defaults to tenant_id
+ physical_index: str | None # dedicated-index key (Step 6.2); None = shared base index
pii_policy: PiiPolicy # per-tenant: redact | mask | encrypt | tag_only | block | allow
trace: TraceContext # OTel span + correlation IDs
budget: Budget # tokens, dollars, wall_ms, max_iter
diff --git a/docs/reference/tenancy.md b/docs/reference/tenancy.md
index 6dffec6..3506395 100644
--- a/docs/reference/tenancy.md
+++ b/docs/reference/tenancy.md
@@ -45,6 +45,8 @@ tenants:
| `pii_policy` | `block` | The request's PII action (`block` / `redact` / `allow`). |
| `namespace` | `id` | Logical-isolation key; backends that namespace natively (e.g. Pinecone) partition on it. |
| `acl_labels` | `[]` | Tenant-wide ACL labels unioned into the principal's labels. |
+| `dedicated_index` | `false` | **Physical** tenancy (Step 6.2): give this tenant its own vector index/collection. |
+| `dedicated_index_name` | `null` | Optional key for the dedicated index; defaults to the tenant `id`. |
| `quota` | uncapped | Per-tenant rate / usage caps (Step 4.5). |
| `corpus_ids` | `[]` | Corpora visible to the tenant. |
@@ -90,13 +92,51 @@ ragctl tenant resolve acme -f rag.yaml # the effective settings the gateway app
## Scope & boundaries
-This step is the *logical-tenancy foundation*. It resolves and threads the
+Step 6.1 is the *logical-tenancy foundation*. It resolves and threads the
per-tenant isolation + governance primitives; the enforcement that builds on them
lands in later Phase-6 steps:
+- **Physical tenancy** (a dedicated index per tenant) — **6.2** (below).
- **ACL push-down** at retrieval (inject the labels into every backend query) — **6.3**.
- **PII egress** enforcement (block / redact answers per policy) — **6.5**.
-- **Physical tenancy** (a dedicated index per tenant) — **6.2**.
+
+## Physical tenancy (Step 6.2)
+
+*Logical* tenancy shares one index and isolates by `tenant_id` filter (+ namespace).
+**Physical** tenancy gives a tenant its own vector index/collection, so its data is
+separate even if a filter were bypassed — for tenants that require hard isolation.
+
+- Set `dedicated_index: true` on the tenant. The resolver produces a
+ **`physical_index`** key (the `dedicated_index_name` if set, else the tenant `id`)
+ on `TenantSettings`, threaded onto `RequestContext.physical_index` at the boundary.
+ A logical-only tenant resolves to `physical_index = None`.
+- A vector backend namespaces its base index under the key: **`-`**, and
+ creates the dedicated index/collection **lazily** on first use. When the key is
+ `None`, the backend uses its shared base index unchanged.
+- Supported backends: the in-memory **Noop** store (the CI conformance oracle, keyed
+ by `physical_index`), **Pinecone** (dedicated index), and **Qdrant** (dedicated
+ collection). pgvector / Elasticsearch / Weaviate follow the same pattern later.
+- **Backends read only `ctx`** — never the resolver/config (the dependency graph is
+ backends → core), so the per-tenant decision arrives via `ctx.physical_index`.
+
+```yaml
+tenants:
+ - id: acme
+ name: Acme Corp
+ dedicated_index: true # → index "-acme"
+ - id: vault
+ name: Vault
+ dedicated_index: true
+ dedicated_index_name: vault-secure # → index "-vault-secure"
+```
+
+`GET /v1/status/tenant` and `ragctl tenant resolve ` report `dedicated_index`
++ the resolved `physical_index`. The **cross-tenant probe gate**
+(`tests/redteam/test_cross_tenant_dedicated_index.py`) proves on the Noop oracle
+that a dedicated tenant's data is invisible to another *independent of the tenant
+filter*; live-service isolation is covered by the Pinecone / Qdrant integration
+tests. See [architecture/multi-tenancy.md](../architecture/multi-tenancy.md) and
+[ADR-0034](../adr/ADR-0034-physical-multi-tenancy.md).
## Extension points
diff --git a/packages/backends/src/rag_backends/vector/pinecone.py b/packages/backends/src/rag_backends/vector/pinecone.py
index b0f4495..134c531 100644
--- a/packages/backends/src/rag_backends/vector/pinecone.py
+++ b/packages/backends/src/rag_backends/vector/pinecone.py
@@ -17,6 +17,12 @@
tenant-id filter. ACL labels and other metadata are stored alongside each vector
and pushed down via the FilterExpr translator.
+For *physical* tenancy (Step 6.2), a tenant with ``ctx.physical_index`` set gets a
+**dedicated index** (``-``), created lazily on first use — so its data
+lives in a separate Pinecone index, not just a namespace within the shared one.
+``ctx.physical_index`` is ``None`` for logical-only tenants, who share the base
+index.
+
The ``pinecone`` Python SDK is an optional extra (``rag-backends[pinecone]``)
— this module imports it lazily inside ``__init__`` so installing the
package without the extra still allows the rest of ``rag_backends.vector``
@@ -114,6 +120,9 @@ def __init__(
self._metric = metric
self._index: _IndexAsyncio | None = None
self._dimension: int | None = None
+ # Physical tenancy (Step 6.2): handles to per-tenant *dedicated* indexes,
+ # keyed by full index name, lazily created + cached on first use.
+ self._tenant_indexes: dict[str, _IndexAsyncio] = {}
# ------------------------------------------------------------------
# Lifecycle
@@ -165,6 +174,9 @@ async def close(self) -> None:
if self._index is not None:
await self._index.close()
self._index = None
+ for handle in self._tenant_indexes.values():
+ await handle.close()
+ self._tenant_indexes.clear()
await self._client.close()
# ------------------------------------------------------------------
@@ -180,7 +192,7 @@ async def bulk_index(
) -> None:
if not embeddings:
return
- index = self._require_index()
+ index = await self._index_for(ctx)
tenant_id = str(ctx.tenant_id)
namespace = str(ctx.namespace)
@@ -208,7 +220,7 @@ async def retrieve_ids(
corpus_ids: list[CorpusId],
filters: FilterExpr | None = None,
) -> list[ChunkRef]:
- index = self._require_index()
+ index = await self._index_for(ctx)
tenant_id = str(ctx.tenant_id)
namespace = str(ctx.namespace)
@@ -258,7 +270,7 @@ async def retrieve_ids(
async def bulk_delete(self, ctx: RequestContext, chunk_ids: list[ChunkId]) -> None:
if not chunk_ids:
return
- index = self._require_index()
+ index = await self._index_for(ctx)
tenant_id = str(ctx.tenant_id)
ids = [_vector_id(tenant_id, str(cid)) for cid in chunk_ids]
await index.delete(ids=ids, namespace=str(ctx.namespace))
@@ -285,6 +297,40 @@ def _require_index(self) -> _IndexAsyncio:
raise RetrievalError("PineconeVectorStore.initialize() not called")
return self._index
+ def _index_name_for(self, ctx: RequestContext) -> str:
+ """The index name for this request — dedicated (``-``) or base."""
+ key = ctx.physical_index
+ return f"{self._index_name}-{key}" if key else self._index_name
+
+ async def _index_for(self, ctx: RequestContext) -> _IndexAsyncio:
+ """Return the index handle for this request, lazily creating a dedicated one.
+
+ Physical tenancy (Step 6.2): a tenant with ``ctx.physical_index`` set gets
+ its own index ``-``, created on first use and cached. Everyone
+ else shares the base index opened by :meth:`initialize`.
+ """
+ name = self._index_name_for(ctx)
+ if name == self._index_name:
+ return self._require_index()
+ handle = self._tenant_indexes.get(name)
+ if handle is None:
+ if self._dimension is None:
+ raise RetrievalError("PineconeVectorStore.initialize() not called")
+ existing = await self._client.list_indexes()
+ if name not in {ix.name for ix in existing}:
+ from pinecone import ServerlessSpec
+
+ await self._client.create_index(
+ name=name,
+ dimension=self._dimension,
+ metric=self._metric,
+ spec=ServerlessSpec(cloud=self._cloud, region=self._region),
+ )
+ _log.info("pinecone dedicated index created", extra={"index": name})
+ handle = self._client.IndexAsyncio(name=name)
+ self._tenant_indexes[name] = handle
+ return handle
+
def _hit_metadata(hit: Any) -> dict[str, Any]:
"""Extract metadata from a Pinecone match (dict or pydantic-like)."""
diff --git a/packages/backends/src/rag_backends/vector/qdrant.py b/packages/backends/src/rag_backends/vector/qdrant.py
index b0b9f75..66ad71f 100644
--- a/packages/backends/src/rag_backends/vector/qdrant.py
+++ b/packages/backends/src/rag_backends/vector/qdrant.py
@@ -7,10 +7,15 @@
await store.bulk_index(ctx, embeddings)
refs = await store.retrieve_ids(ctx, vector, top_k=10, corpus_ids=[])
-A single Qdrant collection (``rag_embeddings`` by default) stores all tenants.
-Tenant isolation is enforced via a ``tenant_id`` payload filter on every query
-and delete operation. This avoids collection-proliferation and makes
+A single Qdrant collection (``rag_embeddings`` by default) stores all
+logical-tenancy tenants, isolated via a ``tenant_id`` payload filter on every
+query and delete operation — avoiding collection-proliferation and keeping
cross-corpus queries efficient.
+
+For *physical* tenancy (Step 6.2), a tenant with ``ctx.physical_index`` set gets a
+**dedicated collection** (``-``), created lazily on first use — so its
+data lives in a separate Qdrant collection, not just a payload-filtered slice of
+the shared one. ``ctx.physical_index`` is ``None`` for logical-only tenants.
"""
from __future__ import annotations
@@ -32,6 +37,7 @@
ScalarType,
VectorParams,
)
+from rag_core.errors import RetrievalError
from rag_core.filter import FilterExpr
from rag_core.spi.vector_store import VectorStore
from rag_core.types import (
@@ -114,6 +120,10 @@ def __init__(
self._url = url
self._collection = collection
self._client: AsyncQdrantClient = AsyncQdrantClient(url=url, api_key=api_key)
+ self._dimension: int | None = None
+ # Physical tenancy (Step 6.2): dedicated per-tenant collections we've
+ # already ensured exist, so we create each at most once.
+ self._ensured: set[str] = set()
# ------------------------------------------------------------------
# Lifecycle
@@ -136,6 +146,7 @@ async def initialize(
deliberately not done here so re-running ``initialize()``
is safe.
"""
+ self._dimension = dimension
exists = await self._client.collection_exists(self._collection)
if not exists:
variant = select_index_variant(hint)
@@ -164,6 +175,35 @@ async def close(self) -> None:
"""Close the underlying HTTP client."""
await self._client.close()
+ def _collection_name_for(self, ctx: RequestContext) -> str:
+ """The collection for this request — dedicated (``-``) or base."""
+ key = ctx.physical_index
+ return f"{self._collection}-{key}" if key else self._collection
+
+ async def _collection_for(self, ctx: RequestContext) -> str:
+ """Return the collection name for this request, creating a dedicated one lazily.
+
+ Physical tenancy (Step 6.2): a tenant with ``ctx.physical_index`` set gets
+ its own collection ``-``, created on first use; everyone else
+ uses the base collection from :meth:`initialize`.
+ """
+ name = self._collection_name_for(ctx)
+ if name == self._collection or name in self._ensured:
+ return name
+ if not await self._client.collection_exists(name):
+ if self._dimension is None:
+ raise RetrievalError("QdrantVectorStore.initialize() not called")
+ hnsw_config, quantization = _qdrant_index_config(select_index_variant(None))
+ await self._client.create_collection(
+ collection_name=name,
+ vectors_config=VectorParams(size=self._dimension, distance=Distance.COSINE),
+ hnsw_config=hnsw_config,
+ quantization_config=quantization,
+ )
+ _log.info("Qdrant dedicated collection created", extra={"collection": name})
+ self._ensured.add(name)
+ return name
+
# ------------------------------------------------------------------
# VectorStore SPI (Step 1.1b split: bulk_index / retrieve_ids / bulk_delete)
# ------------------------------------------------------------------
@@ -196,7 +236,7 @@ async def bulk_index(
)
for emb in embeddings
]
- await self._client.upsert(collection_name=self._collection, points=points)
+ await self._client.upsert(collection_name=await self._collection_for(ctx), points=points)
async def retrieve_ids(
self,
@@ -231,7 +271,7 @@ async def retrieve_ids(
extra_should = list(extra.should)
response = await self._client.query_points(
- collection_name=self._collection,
+ collection_name=await self._collection_for(ctx),
query=vector,
query_filter=Filter(
must=must,
@@ -264,7 +304,7 @@ async def bulk_delete(self, ctx: RequestContext, chunk_ids: list[ChunkId]) -> No
point_ids = [_chunk_uuid(str(ctx.tenant_id), str(cid)) for cid in chunk_ids]
await self._client.delete(
- collection_name=self._collection,
+ collection_name=await self._collection_for(ctx),
points_selector=point_ids, # type: ignore[arg-type]
)
diff --git a/packages/config/src/rag_config/schema.py b/packages/config/src/rag_config/schema.py
index 9bc02cb..6ef0ebf 100644
--- a/packages/config/src/rag_config/schema.py
+++ b/packages/config/src/rag_config/schema.py
@@ -333,6 +333,12 @@ class TenantConfig(_StrictBase):
# Tenant-wide ACL labels unioned into every principal of this tenant. The
# config side of ACLs (Step 6.1); retrieval-time push-down enforcement is 6.3.
acl_labels: list[str] = Field(default_factory=list)
+ # Physical tenancy (Step 6.2): when true, this tenant's vectors live in a
+ # *dedicated* index/collection (``-``), not the shared one — so the
+ # data is separate even if a filter were bypassed. The key is
+ # ``dedicated_index_name`` when set, else the tenant ``id``.
+ dedicated_index: bool = False
+ dedicated_index_name: Annotated[str, Field(min_length=1)] | None = None
# ---------------------------------------------------------------------------
diff --git a/packages/config/src/rag_config/tenancy.py b/packages/config/src/rag_config/tenancy.py
index 57f40bd..e65e02e 100644
--- a/packages/config/src/rag_config/tenancy.py
+++ b/packages/config/src/rag_config/tenancy.py
@@ -38,9 +38,22 @@ def settings_for_tenant(tenant: TenantConfig) -> TenantSettings:
namespace=tenant.namespace or tenant.id,
pii_policy=_pii_policy_from(tenant.pii_policy),
acl_labels=frozenset(tenant.acl_labels),
+ physical_index=_physical_index_for(tenant),
)
+def _physical_index_for(tenant: TenantConfig) -> str | None:
+ """The per-tenant physical-index key (Step 6.2), or ``None`` for shared.
+
+ ``None`` when the tenant isn't physically isolated. Otherwise the explicit
+ ``dedicated_index_name`` if set, else the tenant ``id`` — the backend
+ namespaces its base index under this key (``-``).
+ """
+ if not tenant.dedicated_index:
+ return None
+ return tenant.dedicated_index_name or tenant.id
+
+
class TenantResolver:
"""Resolves a tenant id → :class:`TenantSettings` from a ``RagConfig``.
diff --git a/packages/core/src/rag_core/spi/noop/vector_store.py b/packages/core/src/rag_core/spi/noop/vector_store.py
index 8305581..536cdc6 100644
--- a/packages/core/src/rag_core/spi/noop/vector_store.py
+++ b/packages/core/src/rag_core/spi/noop/vector_store.py
@@ -34,11 +34,23 @@ class NoopVectorStore(VectorStore):
fields are treated as missing (predicates referencing them never match);
this is intentionally permissive for the in-memory test backend — real
backends (pgvector, Qdrant) translate the AST natively.
+
+ Step 6.2 (physical tenancy): entries are partitioned by ``ctx.physical_index``
+ — a tenant with a *dedicated* index lives in its own partition, separate from
+ the shared one and from every other dedicated tenant. This makes the
+ in-memory store the conformance oracle for the cross-tenant probe gate: a
+ read only ever scans its own partition, so isolation holds even if the
+ ``tenant_id`` filter were bypassed.
"""
def __init__(self) -> None:
- # (tenant_id, chunk_id) -> Embedding
- self._store: dict[tuple[str, str], Embedding] = {}
+ # (physical_index, tenant_id, chunk_id) -> Embedding
+ self._store: dict[tuple[str, str, str], Embedding] = {}
+
+ @staticmethod
+ def _index_key(ctx: RequestContext) -> str:
+ """Physical-index partition (Step 6.2): the dedicated key, or shared."""
+ return ctx.physical_index or "__shared__"
@staticmethod
def _embedding_attrs(emb: Embedding) -> dict[str, Any]:
@@ -57,8 +69,9 @@ async def bulk_index(
*,
hint: IndexHint | None = None,
) -> None:
+ index = self._index_key(ctx)
for emb in embeddings:
- self._store[(ctx.tenant_id, emb.chunk_id)] = emb
+ self._store[(index, ctx.tenant_id, emb.chunk_id)] = emb
async def retrieve_ids(
self,
@@ -68,9 +81,12 @@ async def retrieve_ids(
corpus_ids: list[CorpusId],
filters: FilterExpr | None = None,
) -> list[ChunkRef]:
+ index = self._index_key(ctx)
scored: list[tuple[float, Embedding]] = []
- for (tid, _cid), emb in self._store.items():
- if tid != ctx.tenant_id:
+ for (idx, tid, _cid), emb in self._store.items():
+ # Physical isolation (Step 6.2) first, then the tenant filter — a read
+ # never scans another partition, independent of the tenant filter.
+ if idx != index or tid != ctx.tenant_id:
continue
# Filter push-down (Step 2.1) — reference semantics.
if filters is not None and not evaluate(filters, self._embedding_attrs(emb)):
@@ -93,8 +109,9 @@ async def bulk_delete(
ctx: RequestContext,
chunk_ids: list[ChunkId],
) -> None:
+ index = self._index_key(ctx)
for cid in chunk_ids:
- self._store.pop((ctx.tenant_id, cid), None)
+ self._store.pop((index, ctx.tenant_id, cid), None)
async def health(self) -> bool:
return True
diff --git a/packages/core/src/rag_core/types.py b/packages/core/src/rag_core/types.py
index 195fb82..f363414 100644
--- a/packages/core/src/rag_core/types.py
+++ b/packages/core/src/rag_core/types.py
@@ -364,6 +364,11 @@ class RequestContext(BaseModel):
tenant_id: TenantId
principal: Principal
namespace: str = ""
+ # Per-tenant physical-isolation key (Step 6.2). ``None`` → the shared base
+ # index/collection (logical tenancy only); a string → the tenant has a
+ # *dedicated* index and a backend namespaces its base under it
+ # (``-``). Resolved from ``rag.yaml`` at the boundary.
+ physical_index: str | None = None
pii_policy: PiiPolicy = Field(default_factory=PiiPolicy)
trace: TraceContext = Field(default_factory=TraceContext)
budget: Budget = Field(default_factory=Budget)
@@ -435,6 +440,10 @@ class TenantSettings(BaseModel):
namespace: str
pii_policy: PiiPolicy = Field(default_factory=PiiPolicy)
acl_labels: frozenset[str] = Field(default_factory=frozenset)
+ # Per-tenant physical-isolation key (Step 6.2): the index/collection key a
+ # backend namespaces its base under (``-``) when the
+ # tenant has a *dedicated* index. ``None`` → the shared base index.
+ physical_index: str | None = None
# ---------------------------------------------------------------------------
diff --git a/packages/ragctl/src/ragctl/main.py b/packages/ragctl/src/ragctl/main.py
index 2d93548..d5ee401 100644
--- a/packages/ragctl/src/ragctl/main.py
+++ b/packages/ragctl/src/ragctl/main.py
@@ -4675,6 +4675,10 @@ def tenant_resolve(
typer.echo(f"pii_action: {settings.pii_policy.action.value}")
labels = ", ".join(sorted(settings.acl_labels)) or "(none)"
typer.echo(f"acl_labels: {labels}")
+ if settings.physical_index is not None:
+ typer.echo(f"index: dedicated (key={settings.physical_index})")
+ else:
+ typer.echo("index: shared (base index)")
# ---------------------------------------------------------------------------
diff --git a/packages/ragctl/tests/test_tenant.py b/packages/ragctl/tests/test_tenant.py
index 5036d97..18576f2 100644
--- a/packages/ragctl/tests/test_tenant.py
+++ b/packages/ragctl/tests/test_tenant.py
@@ -18,6 +18,7 @@
pii_policy: block
namespace: acme-prod
acl_labels: [region:eu, pii:cleared]
+ dedicated_index: true
- id: beta
name: Beta LLC
pii_policy: allow
@@ -48,6 +49,14 @@ def test_tenant_resolve_known(cfg: Path) -> None:
assert "namespace: acme-prod" in result.output
assert "pii_action: block" in result.output
assert "pii:cleared" in result.output and "region:eu" in result.output
+ # acme is physically isolated → dedicated index keyed by its id.
+ assert "index: dedicated (key=acme)" in result.output
+
+
+def test_tenant_resolve_shared_index(cfg: Path) -> None:
+ result = runner.invoke(app, ["tenant", "resolve", "beta", "-f", str(cfg)])
+ assert result.exit_code == 0, result.output
+ assert "index: shared (base index)" in result.output
def test_tenant_resolve_unknown_uses_safe_defaults(cfg: Path) -> None:
@@ -58,6 +67,7 @@ def test_tenant_resolve_unknown_uses_safe_defaults(cfg: Path) -> None:
assert "namespace: ghost" in result.output
assert "pii_action: redact" in result.output
assert "acl_labels: (none)" in result.output
+ assert "index: shared (base index)" in result.output
def test_tenant_help_works() -> None:
diff --git a/tests/config/test_tenancy.py b/tests/config/test_tenancy.py
index eaf482d..7d0548a 100644
--- a/tests/config/test_tenancy.py
+++ b/tests/config/test_tenancy.py
@@ -84,3 +84,39 @@ def test_pii_enum_maps_one_for_one() -> None:
]:
s = settings_for_tenant(TenantConfig(id="t", name="T", pii_policy=cfg_policy))
assert s.pii_policy.action is action
+
+
+# ---------------------------------------------------------------------------
+# Physical tenancy (Step 6.2)
+# ---------------------------------------------------------------------------
+def test_physical_index_defaults_to_none() -> None:
+ t = TenantConfig(id="x", name="X")
+ assert t.dedicated_index is False
+ assert settings_for_tenant(t).physical_index is None
+
+
+def test_dedicated_index_auto_derives_key_from_id() -> None:
+ s = settings_for_tenant(TenantConfig(id="acme", name="Acme", dedicated_index=True))
+ assert s.physical_index == "acme"
+
+
+def test_dedicated_index_name_overrides_the_key() -> None:
+ s = settings_for_tenant(
+ TenantConfig(
+ id="acme", name="Acme", dedicated_index=True, dedicated_index_name="acme-vault"
+ )
+ )
+ assert s.physical_index == "acme-vault"
+
+
+def test_dedicated_index_name_ignored_when_flag_off() -> None:
+ # A name without the flag does not opt the tenant into a dedicated index.
+ s = settings_for_tenant(TenantConfig(id="acme", name="Acme", dedicated_index_name="acme-vault"))
+ assert s.physical_index is None
+
+
+def test_unknown_tenant_has_no_dedicated_index() -> None:
+ r = TenantResolver.from_config(
+ RagConfig(tenants=[TenantConfig(id="acme", name="Acme", dedicated_index=True)])
+ )
+ assert r.resolve("ghost").physical_index is None
diff --git a/tests/redteam/test_cross_tenant_dedicated_index.py b/tests/redteam/test_cross_tenant_dedicated_index.py
new file mode 100644
index 0000000..7431c92
--- /dev/null
+++ b/tests/redteam/test_cross_tenant_dedicated_index.py
@@ -0,0 +1,120 @@
+"""Cross-tenant probe gate: a dedicated index is *physically* isolated (Step 6.2).
+
+Physical tenancy gives a tenant its own vector index/collection (``-``),
+so its data is separate even if the ``tenant_id`` filter were bypassed. This gate
+proves that on the in-memory ``NoopVectorStore`` (the conformance oracle, keyed by
+``ctx.physical_index``): a read only ever scans its own physical partition, so
+isolation does **not** depend on the logical tenant filter. The same guarantee on
+Pinecone / Qdrant is covered by the live-service integration tests.
+"""
+
+from __future__ import annotations
+
+from rag_config import RagConfig, TenantConfig, TenantResolver
+from rag_core.spi.noop.vector_store import NoopVectorStore
+from rag_core.types import (
+ ChunkId,
+ Embedding,
+ Principal,
+ PrincipalId,
+ PrincipalKind,
+ RequestContext,
+ TenantId,
+)
+
+_RESOLVER = TenantResolver.from_config(
+ RagConfig(
+ tenants=[
+ TenantConfig(id="acme", name="Acme", dedicated_index=True),
+ TenantConfig(id="globex", name="Globex", dedicated_index=True),
+ TenantConfig(id="shared", name="Shared"), # logical-only
+ ]
+ )
+)
+
+
+def _ctx(tenant: str, *, physical_index: str | None) -> RequestContext:
+ tid = TenantId(tenant)
+ return RequestContext(
+ tenant_id=tid,
+ principal=Principal(
+ id=PrincipalId("p"),
+ kind=PrincipalKind.service,
+ display_name="p",
+ tenant_id=tid,
+ ),
+ physical_index=physical_index,
+ )
+
+
+def _resolved_ctx(tenant: str) -> RequestContext:
+ """Build a ctx with the resolver-computed physical_index (the gateway's path)."""
+ return _ctx(tenant, physical_index=_RESOLVER.resolve(tenant).physical_index)
+
+
+def _emb(chunk_id: str, tenant: str) -> Embedding:
+ return Embedding(
+ chunk_id=ChunkId(chunk_id),
+ tenant_id=TenantId(tenant),
+ vector=[1.0, 0.0],
+ dimension=2,
+ model="m",
+ )
+
+
+async def test_resolver_assigns_distinct_dedicated_indexes() -> None:
+ assert _RESOLVER.resolve("acme").physical_index == "acme"
+ assert _RESOLVER.resolve("globex").physical_index == "globex"
+ assert _RESOLVER.resolve("shared").physical_index is None
+
+
+async def test_dedicated_tenant_data_invisible_to_another_dedicated_tenant() -> None:
+ store = NoopVectorStore()
+ await store.bulk_index(_resolved_ctx("acme"), [_emb("a1", "acme")])
+ # globex queries its own dedicated index → never sees acme's chunk.
+ refs = await store.retrieve_ids(_resolved_ctx("globex"), [1.0, 0.0], top_k=10, corpus_ids=[])
+ assert refs == []
+
+
+async def test_isolation_is_physical_not_just_the_tenant_filter() -> None:
+ """Even with a matching tenant_id, querying a *different* index returns nothing."""
+ store = NoopVectorStore()
+ await store.bulk_index(_resolved_ctx("acme"), [_emb("a1", "acme")])
+ # Same tenant_id (acme) — so the tenant filter WOULD match — but pointed at the
+ # shared index instead of acme's dedicated one: the data is physically elsewhere.
+ refs = await store.retrieve_ids(
+ _ctx("acme", physical_index=None), [1.0, 0.0], top_k=10, corpus_ids=[]
+ )
+ assert refs == []
+ # ...and pointed at globex's index: still nothing.
+ refs2 = await store.retrieve_ids(
+ _ctx("acme", physical_index="globex"), [1.0, 0.0], top_k=10, corpus_ids=[]
+ )
+ assert refs2 == []
+
+
+async def test_dedicated_tenant_sees_its_own_data() -> None:
+ store = NoopVectorStore()
+ await store.bulk_index(_resolved_ctx("acme"), [_emb("a1", "acme")])
+ refs = await store.retrieve_ids(_resolved_ctx("acme"), [1.0, 0.0], top_k=10, corpus_ids=[])
+ assert [str(r.chunk_id) for r in refs] == ["a1"]
+
+
+async def test_shared_and_dedicated_partitions_do_not_cross() -> None:
+ store = NoopVectorStore()
+ # A logical-only tenant writes into the shared partition.
+ await store.bulk_index(_resolved_ctx("shared"), [_emb("s1", "shared")])
+ # A dedicated tenant cannot see shared-partition data...
+ assert await store.retrieve_ids(_resolved_ctx("acme"), [1.0, 0.0], 10, []) == []
+ # ...and the shared tenant only sees its own.
+ refs = await store.retrieve_ids(_resolved_ctx("shared"), [1.0, 0.0], 10, [])
+ assert [str(r.chunk_id) for r in refs] == ["s1"]
+
+
+async def test_delete_is_scoped_to_the_tenants_index() -> None:
+ store = NoopVectorStore()
+ await store.bulk_index(_resolved_ctx("acme"), [_emb("a1", "acme")])
+ # A delete issued against the shared index must not touch acme's dedicated data.
+ await store.bulk_delete(_ctx("acme", physical_index=None), [ChunkId("a1")])
+ refs = await store.retrieve_ids(_resolved_ctx("acme"), [1.0, 0.0], 10, [])
+ assert [str(r.chunk_id) for r in refs] == ["a1"] # still there