Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@

| | |
|---|---|
| **Last updated** | 2026-06-07 |
| **Current phase** | **Phase 5Eval & Observability ✅ complete (7 / 7 steps)** → starting Phase 6 |
| **Overall** | **64 / 84 steps** — Phases 0–5 complete |
| **Next action** | **Step 6.1Logical multi-tenancy** (Phase 6 — Governance & Tenancy): namespace isolation; per-tenant config, quotas, ACLs in `rag.yaml`. First step of the governance phase. |
| **Last updated** | 2026-06-08 |
| **Current phase** | Phase 6Governance & Tenancy (**1 / 10 steps**) |
| **Overall** | **65 / 84 steps** — Phases 0–5 complete |
| **Next action** | **Step 6.2Physical tenancy (dedicated index)**: dedicated vector index per tenant; cross-tenant probe gate. Builds on the 6.1 namespace primitive. |

**Recently shipped**

- **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)
- **5.7b** ✅ Shadow mode — observe-only candidate fan-out (`ShadowRunner`, background task) feeding the A/B tracker — [#145](https://github.com/officialCodeWork/AgentContextOS/pull/145)
Expand Down Expand Up @@ -54,9 +55,9 @@
| 3 | Gateway & Agent Runtime | 11 | **11** | 0 |
| 4 | Reliability | 6 | **6** | 0 |
| 5 | Eval & Observability | 7 | **7** | 0 |
| 6 | Governance & Tenancy | 10 | 0 | 10 |
| 6 | Governance & Tenancy | 10 | **1** | 9 |
| 7 | Pilot, Harden, GA | 10 | 0 | 10 |
| **Total** | | **84** | **64** | **20** |
| **Total** | | **84** | **65** | **19** |

---

Expand Down Expand Up @@ -639,11 +640,11 @@
- **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)
## Phase 6 — Governance & Tenancy (Weeks 28–34) 🚧 (1 / 10)

| Step | Title | Status | Planned deliverables |
|------|-------|:------:|----------------------|
| 6.1 | Logical multi-tenancy | | Namespace isolation; per-tenant config, quotas, ACLs in `rag.yaml` |
| 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.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 |
Expand All @@ -654,6 +655,16 @@
| 6.9 | Air-gapped install bundle | ⏳ | Signed tarball with all images + Helm chart; offline bootstrap; cosign verification |
| 6.10 | Compliance posture | ⏳ | SOC 2 Type II control mapping; GDPR data-residency config; data-retention policies |

### 6.1 — Logical multi-tenancy ✅ [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148)

- Makes per-tenant `rag.yaml` config actually drive requests (it was a passive registry before; only quotas were per-tenant). New `TenantResolver` (`rag_config.tenancy`) maps a tenant id → frozen **`TenantSettings`** (`rag_core.types`: namespace / pii_policy / acl_labels), merging the `tenants:` entry with safe defaults
- **Resolved once, at the boundary:** the request-context middleware (`build_gateway_context`) applies the resolved settings — `namespace` + `pii_policy` onto the `RequestContext`, the tenant's `acl_labels` **unioned** into the principal's; the resolved view is also exposed on `request.state.gateway.tenant_settings`
- **Unknown tenants resolve to safe defaults** (namespace = the id, default PII `redact`, no labels) — isolated, never privileged
- New **`RequestContext.namespace`** (defaults to `tenant_id` via a before-validator, so every pre-6.1 call site is unchanged); it's a **backend-partition** primitive — Pinecone now partitions on `ctx.namespace` — **not** a chunk field, so `filter_pushdown` is unchanged (chunks carry `tenant_id`)
- `TenantConfig` gains `namespace` + `acl_labels` (→ `rag.schema`); `TenantSettings` joins `dist/schemas` + `RequestContext` regenerated; **`GET /v1/status/tenant`** (→ `dist/openapi`) surfaces the resolved view; `ragctl tenant list` / `resolve <id>` print it; **inert by default** in `build_app`, built from `cfg.tenants` by `build_app_from_config`
- **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)

---

## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳
Expand Down Expand Up @@ -805,6 +816,7 @@ Complete log of every PR. Routine Dependabot bumps are grouped; everything else
| [#145](https://github.com/officialCodeWork/AgentContextOS/pull/145) | 2026-06-05 | feat(experiments): shadow mode — observe-only candidate fan-out (Step 5.7b) |
| [#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) |
| #78–#80, #116–#118 | Open | Dependabot bumps — awaiting merge |
| #81 | Closed | Dependabot bump — superseded |

Expand Down
7 changes: 7 additions & 0 deletions apps/gateway/src/rag_gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ def build_app(
experiment_tracker: Any | None = None,
shadow_runner: Any | None = None,
ab_router: Any | None = None,
tenant_resolver: Any | None = None,
enable_cors: bool = True,
default_tenant_id: TenantId | None = None,
) -> FastAPI:
Expand Down Expand Up @@ -611,10 +612,16 @@ def build_app(
# OpenAI-compatible endpoints) can synthesise an anonymous principal
# in dev mode and require real auth when this is None in production.
app.state.default_tenant_id = effective_default_tenant
# Per-tenant settings resolver (Step 6.1) — makes per-tenant rag.yaml config
# (namespace / PII policy / ACL labels) drive each request. ``None`` (the
# plain ``build_app`` default) keeps the pre-6.1 behaviour; the config-driven
# wiring builds one from ``cfg.tenants``.
app.state.tenant_resolver = tenant_resolver
install_request_context_middleware(
app,
auth=auth_backend,
default_tenant_id=effective_default_tenant,
tenant_resolver=tenant_resolver,
)
# Metrics middleware is added after request-context (so it wraps it and times
# the full handler). CORS is added last so it is the outermost layer and can
Expand Down
46 changes: 44 additions & 2 deletions apps/gateway/src/rag_gateway/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import re
import uuid
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING

from fastapi import Request, Response
from rag_core import get_logger
Expand All @@ -47,9 +48,13 @@
RequestContext,
RequestId,
TenantId,
TenantSettings,
TraceContext,
)

if TYPE_CHECKING:
from rag_config.tenancy import TenantResolver

_log = get_logger(__name__)

# W3C Trace Context — https://www.w3.org/TR/trace-context/
Expand All @@ -71,7 +76,7 @@ class GatewayContext:
Route handlers decide what to do per-route.
"""

__slots__ = ("ctx", "auth_error", "request_id", "trace")
__slots__ = ("ctx", "auth_error", "request_id", "trace", "tenant_settings")

def __init__(
self,
Expand All @@ -80,18 +85,23 @@ def __init__(
auth_error: AuthError | None,
request_id: RequestId,
trace: TraceContext,
tenant_settings: TenantSettings | None = None,
) -> None:
self.ctx = ctx
self.auth_error = auth_error
self.request_id = request_id
self.trace = trace
# The resolved per-tenant view (Step 6.1) when a resolver is wired; the
# namespace / pii_policy / unioned acl_labels are already on ``ctx``.
self.tenant_settings = tenant_settings


async def build_gateway_context(
request: Request,
*,
auth: Auth,
default_tenant_id: TenantId | None = None,
tenant_resolver: TenantResolver | None = None,
) -> GatewayContext:
"""Read headers, resolve principal via ``auth``, return a GatewayContext.

Expand All @@ -103,6 +113,13 @@ async def build_gateway_context(
``"gateway-anon"`` when the caller didn't supply a tenant; in
production this should be ``None`` so a missing tenant header
becomes an explicit auth failure.

``tenant_resolver`` (Step 6.1) makes per-tenant ``rag.yaml`` config drive the
request: when wired, the tenant's resolved namespace + PII policy land on the
:class:`RequestContext` and the tenant's ACL labels are unioned into the
principal's. ``None`` (the plain ``build_app`` default) leaves the pre-6.1
behaviour — the namespace falls back to the tenant id and the PII policy to the
default.
"""
headers = request.headers

Expand Down Expand Up @@ -143,12 +160,27 @@ async def build_gateway_context(
tenant_id=tenant_id,
)

# Per-tenant settings (Step 6.1) — resolve the tenant's namespace / PII policy
# / ACL labels from rag.yaml and apply them once, here at the boundary.
tenant_settings: TenantSettings | None = None
ctx: RequestContext | None = None
if principal is not None and tenant_id is not None:
namespace = str(tenant_id)
pii_policy = 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
if tenant_settings.acl_labels:
principal = principal.model_copy(
update={"acl_labels": principal.acl_labels | tenant_settings.acl_labels}
)
ctx = RequestContext(
request_id=request_id,
tenant_id=tenant_id,
principal=principal,
namespace=namespace,
**({"pii_policy": pii_policy} if pii_policy is not None else {}),
trace=trace,
)

Expand All @@ -157,6 +189,7 @@ async def build_gateway_context(
auth_error=auth_error,
request_id=request_id,
trace=trace,
tenant_settings=tenant_settings,
)


Expand Down Expand Up @@ -184,7 +217,11 @@ def _parse_traceparent(raw: str | None) -> TraceContext:


def install_request_context_middleware(
app: Any, *, auth: Auth, default_tenant_id: TenantId | None = None
app: Any,
*,
auth: Auth,
default_tenant_id: TenantId | None = None,
tenant_resolver: TenantResolver | None = None,
) -> None: # noqa: F821
"""Install the per-request middleware on a FastAPI app.

Expand All @@ -196,6 +233,10 @@ def install_request_context_middleware(
The middleware also stamps the ``X-Request-Id`` header on every
response so callers can correlate logs without re-reading the
request body.

``tenant_resolver`` (Step 6.1) is forwarded to
:func:`build_gateway_context` so per-tenant ``rag.yaml`` config drives the
request; ``None`` keeps the pre-6.1 behaviour.
"""

@app.middleware("http") # type: ignore[untyped-decorator]
Expand All @@ -207,6 +248,7 @@ async def _request_context_middleware(
request,
auth=auth,
default_tenant_id=default_tenant_id,
tenant_resolver=tenant_resolver,
)
request.state.gateway = gateway
response = await call_next(request)
Expand Down
53 changes: 53 additions & 0 deletions apps/gateway/src/rag_gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from rag_config.eval import analyze_ab_experiment
from rag_config.tenancy import TenantResolver
from rag_core.eval import ABAnalysisResult
from rag_core.types import (
BreakerSnapshot,
Expand Down Expand Up @@ -313,6 +314,26 @@ class CostStatusResponse(BaseModel):
baseline_cost_micros: float = 0.0


class TenantStatusResponse(BaseModel):
"""One tenant's resolved logical-tenancy settings (Step 6.1).

The effective per-tenant view the gateway applies to each request, resolved by
the :class:`~rag_config.tenancy.TenantResolver` from ``cfg.tenants``. ``known``
is whether the tenant is declared in ``rag.yaml`` — an unknown tenant resolves
to safe defaults (namespace = the tenant id, default PII action, no labels), so
it is isolated rather than privileged. Per-tenant like quotas / cost: the
tenant comes from the ``tenant_id`` query param or the ``X-Tenant-Id`` header.
"""

model_config = {"frozen": True}

tenant_id: str
known: bool = False
namespace: str = ""
pii_action: str = "redact"
acl_labels: list[str] = Field(default_factory=list)


class ExperimentsStatusResponse(BaseModel):
"""A/B experiment comparisons (Step 5.7).

Expand Down Expand Up @@ -847,6 +868,38 @@ async def status_experiments(request: Request) -> ExperimentsStatusResponse:
]
return ExperimentsStatusResponse(experiments=results, total=len(results))

@router.get("/v1/status/tenant", response_model=TenantStatusResponse)
async def status_tenant(request: Request, tenant_id: str | None = None) -> TenantStatusResponse:
"""Resolved per-tenant logical-tenancy settings (Step 6.1).

Surfaces what the ``TenantResolver`` resolves for a tenant — its
namespace, PII action, and ACL labels — so an operator can see the
effective config a tenant's requests run under. Tenant resolves from the
``tenant_id`` query param, else the ``X-Tenant-Id`` header, else the
gateway default. Reports safe defaults (``known=false``) when the tenant
is absent from ``rag.yaml`` or no resolver is wired.
"""
resolver = getattr(request.app.state, "tenant_resolver", None)
tid = str(
tenant_id
or request.headers.get("X-Tenant-Id")
or getattr(request.app.state, "default_tenant_id", None)
or "default"
)
if resolver is not None:
settings = resolver.resolve(tid)
known = resolver.known(tid)
else:
settings = TenantResolver.default_settings(tid)
known = False
return TenantStatusResponse(
tenant_id=tid,
known=known,
namespace=settings.namespace,
pii_action=settings.pii_policy.action.value,
acl_labels=sorted(settings.acl_labels),
)

@router.websocket("/v1/status/ws")
async def status_ws(websocket: WebSocket) -> None:
"""Push health + metrics snapshots until the client disconnects.
Expand Down
8 changes: 8 additions & 0 deletions apps/gateway/src/rag_gateway/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
CorpusStoreProvider,
RagConfig,
TenantQuota,
TenantResolver,
)
from rag_config import (
CorpusRoutingStrategy as CfgStrategy,
Expand Down Expand Up @@ -687,6 +688,13 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI:
cfg, overrides.get("experiment_tracker")
)

# Logical multi-tenancy (Step 6.1) — resolve per-tenant rag.yaml config
# (namespace / PII policy / ACL labels) at the request boundary. Always built
# from ``cfg.tenants`` (an unknown tenant resolves to safe defaults) unless a
# caller injects its own.
if "tenant_resolver" not in overrides:
overrides["tenant_resolver"] = TenantResolver.from_config(cfg)

# Phase-5 quality/cost signals as Prometheus metrics (Step 5.6e) — expose the
# drift report + per-tenant cost verdicts as OTel observable gauges on the
# same pipeline that carries ``rag.spi.*`` so Grafana can graph them. The
Expand Down
Loading
Loading