diff --git a/TRACKER.md b/TRACKER.md
index 46262e9..103dff4 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -14,12 +14,13 @@
| | |
|---|---|
| **Last updated** | 2026-06-08 |
-| **Current phase** | Phase 6 — Governance & Tenancy (**9 / 10 steps**) |
-| **Overall** | **73 / 84 steps** — Phases 0–5 complete |
-| **Next action** | **Step 6.10 — Compliance posture**: SOC 2 Type II control mapping; GDPR data-residency config; data-retention policies. |
+| **Current phase** | Phase 7 — Pilot, Harden, GA (**0 / 10 steps**) — Phase 6 ✅ complete |
+| **Overall** | **74 / 84 steps** — Phases 0–6 complete |
+| **Next action** | **Step 7.1 — Load testing**: Locust suite; 1000 QPS sustained; p99 < 500 ms; chaos under load. |
**Recently shipped**
+- **6.10** ✅ Compliance posture (Phase 6 capstone) — new `rag-compliance` package: `RetentionEnforcer` drives tenant-scoped `purge_*` (data retention + GDPR right-to-erasure) over the Feedback/Provenance stores (non-abstract `purge_before`/`purge_tenant` SPI capability with `dry_run` so a preview counts-without-deleting; audit chain never purged in place — its retention is the 6.6b WORM export); `POST /v1/compliance/erase` (tenant-scoped, dry-run default + two-flag delete); per-tenant `data_region` enforced at ingest (`ResidencyViolationError` → 403); `GET /v1/status/compliance` posture + `compliance_posture`/`residency_ok`; PII-free `compliance.*` events; `cfg.compliance` + `tenants[].data_region`/`retention_days`; `ragctl compliance report`/`demo`; SOC 2 + GDPR control-mapping docs backed by the live posture — [#162](https://github.com/officialCodeWork/AgentContextOS/pull/162)
- **6.9** ✅ Air-gapped install bundle — `ragctl airgap build`/`inspect`/`verify`/`install`: one signed `.tar.gz` of all runtime images (`docker save`) + the packaged Helm chart + `rag.yaml` + a standalone installer, for networks with no registry / internet. Integrity = a standard `SHA256SUMS` (verifiable with `sha256sum -c`, no cosign/network) pinned as `manifest.content_hash` + an optional cosign signature over it (the 6.6b content-hash + optional-signature pattern); the same `SHA256SUMS` drives the Python verifier and the standalone `install.{sh,ps1}` (need only docker+helm). Typed/tested logic in `ragctl.airgap` (pure core + stubbable docker/helm/cosign seam; `--dry-run` = verifiable bundle minus blobs); digest-pinned manifest-driven image set (`infra/airgap/images.txt`); `task airgap:*`; `release-airgap.yml` on tags — [#161](https://github.com/officialCodeWork/AgentContextOS/pull/161)
- **6.8** ✅ SSO / SCIM — `rag-sso`: `FederatedAuth` (an `Auth` SPI backend over per-tenant `OidcProvider` / `SamlProvider`) federates a bearer token / SAML assertion → `Principal` at the existing `authenticate` seam (group claims → `acl_labels`); dependency-free defaults (stdlib HS256 JWT + `defusedxml` SAML), asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; algorithm-allowlist downgrade defense; SCIM 2.0 `ScimService` over the new tenant-scoped `ScimStore` SPI driving `/scim/v2/{Users,Groups}` (per-tenant bearer token); per-tenant IdP on `tenants[].sso`; `cfg.sso` / `cfg.scim`; `GET /v1/status/sso`; PII-free `sso.*` / `scim.*` events; `ragctl sso` / `scim` — [#159](https://github.com/officialCodeWork/AgentContextOS/pull/159)
- **6.7** ✅ BYOK envelope encryption — `KeyManager` SPI + `EncryptingStorage` + `EnvelopeKeyManager` (AES-256-GCM DEK + tenant AAD); `LocalKeyManager` + four cloud KMS providers (`Aws`/`Gcp`/`AzureKeyVault`/`Vault`, behind `[kms-*]` extras); `cfg.kms` + per-tenant key + factory; per-tenant isolation + sealing + tamper-evidence; zero-downtime rotation (`RotatingKeyManager` + `rewrap`); `ragctl kms` — [#155](https://github.com/officialCodeWork/AgentContextOS/pull/155)–[#158](https://github.com/officialCodeWork/AgentContextOS/pull/158)
@@ -63,9 +64,9 @@
| 3 | Gateway & Agent Runtime | 11 | **11** | 0 |
| 4 | Reliability | 6 | **6** | 0 |
| 5 | Eval & Observability | 7 | **7** | 0 |
-| 6 | Governance & Tenancy | 10 | **9** | 1 |
+| 6 | Governance & Tenancy | 10 | **10** | 0 |
| 7 | Pilot, Harden, GA | 10 | 0 | 10 |
-| **Total** | | **84** | **73** | **11** |
+| **Total** | | **84** | **74** | **10** |
---
@@ -648,7 +649,7 @@
- **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) ✅
| Step | Title | Status | Planned deliverables |
|------|-------|:------:|----------------------|
@@ -661,7 +662,7 @@
| 6.7 | BYOK (Bring Your Own Key) | ✅ | **6.7a** ✅ [#155](https://github.com/officialCodeWork/AgentContextOS/pull/155) — `KeyManager` SPI + envelope encryption (`LocalKeyManager`) + `EncryptingStorage` + sealing/isolation/tamper. **6.7b** ✅ [#156](https://github.com/officialCodeWork/AgentContextOS/pull/156) — `cfg.kms` + per-tenant key + `build_key_manager_from_config` factory + `AwsKmsKeyManager`. **6.7c** ✅ [#157](https://github.com/officialCodeWork/AgentContextOS/pull/157) — `GcpKmsKeyManager` / `AzureKeyVaultKeyManager` / `VaultKeyManager` behind `[kms-*]` extras. **6.7d** ✅ [#158](https://github.com/officialCodeWork/AgentContextOS/pull/158) — `RotatingKeyManager` zero-downtime rotation + `rewrap` |
| 6.8 | SSO / SCIM | ✅ | [#159](https://github.com/officialCodeWork/AgentContextOS/pull/159) — `rag-sso`: `FederatedAuth` (`Auth` SPI backend over per-tenant `OidcProvider` / `SamlProvider`) federates a token / assertion → `Principal` at the existing `authenticate` seam (groups → `acl_labels`); stdlib HS256 + `defusedxml` defaults, asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; SCIM 2.0 `ScimService` + `ScimStore` SPI → `/scim/v2/*` (per-tenant bearer); `tenants[].sso` + `cfg.sso` / `cfg.scim`; `GET /v1/status/sso`; `ragctl sso` / `scim` |
| 6.9 | Air-gapped install bundle | ✅ | [#161](https://github.com/officialCodeWork/AgentContextOS/pull/161) — `ragctl airgap build`/`verify`/`install`: signed `.tar.gz` of all images (`docker save`) + Helm chart + config + standalone installer; `SHA256SUMS` content-hash (offline-verifiable) + optional cosign; `infra/airgap/images.txt`; `install.{sh,ps1}`; `task airgap:*`; `release-airgap.yml` |
-| 6.10 | Compliance posture | ⏳ | SOC 2 Type II control mapping; GDPR data-residency config; data-retention policies |
+| 6.10 | Compliance posture | ✅ | [#162](https://github.com/officialCodeWork/AgentContextOS/pull/162) — `rag-compliance`: `RetentionEnforcer` (data retention + GDPR erasure via `purge_*` SPI capability, `dry_run`) + `POST /v1/compliance/erase`; per-tenant `data_region` enforced at ingest (403); `GET /v1/status/compliance` posture; `cfg.compliance`; PII-free `compliance.*`; `ragctl compliance`; SOC 2 + GDPR mapping docs |
### 6.1 — Logical multi-tenancy ✅ [#148](https://github.com/officialCodeWork/AgentContextOS/pull/148)
@@ -780,6 +781,16 @@ New ground — the only prior crypto was HMAC signing. The V1 plan calls for en
- **Digest-pinned, manifest-driven image set:** `infra/airgap/images.txt` lists the third-party images (tags); the build resolves each to its digest into `manifest.json`. The gateway image is added from the chart's `image.repository` + `--version`, so the app image is always release-versioned. **Key-based cosign** is the air-gap recommendation (keyless needs Rekor + a deployment-specific identity policy); the release path (`release-airgap.yml` on `v*` tags) signs keyless and attaches the tarball to the GitHub Release
- **Scope:** docker-based image load + the gateway chart. **Deferred:** `ctr`/podman load, registry re-tag/push, multi-arch selection, bundling the *backend* charts (chart deploys the gateway; backends stay external), TUF-rooted offline keyless verify. `task airgap:build` / `build-dry` / `verify`; ~17 tests (manifest / hashing / tamper detection / tarball round-trip / signature paths via stubbed cosign / CLI on a dry-run bundle), shell installer syntax-checked + exercised via `--verify-only`. All gates green (ruff, mypy --strict, RAG001, schema/openapi/config-drift, policy-coverage, log-schema). [ADR-0041](docs/adr/ADR-0041-airgap-bundle.md), [reference/airgap.md](docs/reference/airgap.md), [architecture/airgap-bundle.md](docs/architecture/airgap-bundle.md), [guides/airgap-install.md](docs/guides/airgap-install.md)
+### 6.10 — Compliance posture ✅ [#162](https://github.com/officialCodeWork/AgentContextOS/pull/162)
+
+- **Phase 6 capstone.** The platform already shipped the *controls* an auditor maps to (audit / ACL / PII / BYOK / SSO / quotas / breakers / guard); 6.10 adds the three missing pieces — **data retention**, **GDPR erasure / residency**, and a **machine-readable posture** that backs the SOC 2 / GDPR mapping
+- **New `rag-compliance` package** (deps rag-core + rag-observability only, config-free — like rag-feedback / rag-drift): **`RetentionEnforcer`** drives tenant-scoped `purge_*` (`purge` = per-class windows, `erase_tenant` = GDPR right-to-erasure); **`compliance_posture`** / **`residency_ok`** are pure
+- **Retention is a *capability on the existing stores*, not a new SPI**: non-abstract **`purge_before(ctx, cutoff, *, dry_run)`** + **`purge_tenant(ctx, *, dry_run)`** on `FeedbackStore` + `ProvenanceStore` (default no-op → no backend breaks). `dry_run` lives in the SPI so a preview **counts without deleting** uniformly (`ProvenanceStore` has no `list`). The **audit chain is never purged in place** (it would break tamper-evidence) — audit retention is the Step 6.6b WORM export; `RetentionPolicy.audit_days` is advisory
+- **Right-to-erasure** — `POST /v1/compliance/erase` erases the **calling tenant's** data (scope from the principal, never the body); always available (a tenant can always erase its own data), **dry-run by default**, real delete needs `dry_run=false` **and** `confirm=true`
+- **Data residency** — `tenants[].data_region` resolves onto `TenantSettings` (reuses the 6.1 resolver) and is enforced at ingest against `cfg.compliance.region`; a mismatch raises **`ResidencyViolationError`** (→ 403) + `compliance.residency_violation`. Opt-in (`cfg.compliance.enabled`), lenient when no region declared — a single-deployment assertion + guard, not multi-region routing
+- **The SOC 2 / GDPR mapping is a doc *backed by a live posture*** — **`GET /v1/status/compliance`** serves a `CompliancePosture` (controls + region + retention) computed from the build_app flags; the mapping docs map each Trust Service Criterion / GDPR article to a control, and the posture reports whether it is **on in this deployment** (so the mapping is checkable, not just prose)
+- New core types **`DataClass` / `RetentionPolicy` / `ErasureResult` / `CompliancePosture`** + **`ComplianceError`** (400) / **`ResidencyViolationError`** (403); `ComplianceEraseRequest` wire type; `cfg.compliance` + `tenants[].data_region` / `retention_days` (`dist/rag.schema`); PII-free **`compliance.*`** events; **`ragctl compliance report`** (posture from cfg) + **`compliance demo`**. **Scope:** tenant-level erasure + feedback/provenance retention. **Deferred:** subject-level chunk/document erasure (needs an index delete-by-document path), an admin retention-*sweep* endpoint (today the sweep is `ragctl`/cron; erasure is the API), multi-region routing, automated audit-evidence bundles. ~30 tests (enforcer purge/erase/dry-run + posture + residency unit; `purge_*` contract suite; gateway posture + erase + residency-403; ragctl). All gates green (ruff, mypy --strict, RAG001, schema/openapi/config-drift, policy-coverage, log-schema/event-registry/PII). [ADR-0042](docs/adr/ADR-0042-compliance-posture.md), [reference/compliance.md](docs/reference/compliance.md), [architecture/compliance.md](docs/architecture/compliance.md), [compliance/soc2-control-mapping.md](docs/compliance/soc2-control-mapping.md), [compliance/gdpr-mapping.md](docs/compliance/gdpr-mapping.md)
+
---
## Phase 7 — Pilot, Harden, GA (Weeks 34–40) ⏳
diff --git a/apps/gateway/pyproject.toml b/apps/gateway/pyproject.toml
index 5822aae..cc98735 100644
--- a/apps/gateway/pyproject.toml
+++ b/apps/gateway/pyproject.toml
@@ -33,6 +33,7 @@ dependencies = [
"rag-agent",
"rag-webhooks",
"rag-sso",
+ "rag-compliance",
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"python-multipart>=0.0.9",
@@ -80,6 +81,7 @@ rag-drift = { workspace = true }
rag-agent = { workspace = true }
rag-webhooks = { workspace = true }
rag-sso = { workspace = true }
+rag-compliance = { workspace = true }
[tool.hatch.build.targets.wheel]
packages = ["src/rag_gateway"]
diff --git a/apps/gateway/src/rag_gateway/app.py b/apps/gateway/src/rag_gateway/app.py
index 977ac25..a4fa610 100644
--- a/apps/gateway/src/rag_gateway/app.py
+++ b/apps/gateway/src/rag_gateway/app.py
@@ -115,6 +115,7 @@
from rag_gateway.agent import build_default_agent_loop, make_agent_router
from rag_gateway.audit import make_audit_router
+from rag_gateway.compliance import make_compliance_router
from rag_gateway.corpora import make_corpora_router
from rag_gateway.feedback import make_feedback_router
from rag_gateway.middleware import install_request_context_middleware
@@ -137,6 +138,43 @@
_log = get_logger(__name__)
+def _enforce_residency(state: Any, tenant_id: str) -> None:
+ """Refuse ingest when a tenant's required region != the deployment region (Step 6.10).
+
+ No-op unless ``cfg.compliance.enabled``. Resolves the tenant's ``data_region``
+ via the Step 6.1 resolver and compares against ``cfg.compliance.region``;
+ a mismatch raises :class:`~rag_core.errors.ResidencyViolationError` (HTTP 403)
+ and emits a PII-free ``compliance.residency_violation`` event.
+ """
+ if not getattr(state, "compliance_enabled", False):
+ return
+ resolver = getattr(state, "tenant_resolver", None)
+ if resolver is None:
+ return
+ from rag_compliance import residency_ok
+ from rag_core.errors import ResidencyViolationError
+ from rag_observability.events import EVT_COMPLIANCE_RESIDENCY_VIOLATION, ComplianceEvent
+
+ region = getattr(state, "compliance_region", "")
+ tenant_region = resolver.resolve(str(tenant_id)).data_region
+ if residency_ok(tenant_region, region):
+ return
+ _log.warning(
+ ComplianceEvent(
+ event_name=EVT_COMPLIANCE_RESIDENCY_VIOLATION,
+ tenant_id=str(tenant_id),
+ operation="residency_violation",
+ region=region,
+ tenant_region=tenant_region or "",
+ ).model_dump_json()
+ )
+ raise ResidencyViolationError(
+ "tenant data region does not match this deployment's region",
+ tenant_region=tenant_region,
+ deployment_region=region,
+ )
+
+
# ---------------------------------------------------------------------------
# Default wiring — dev / smoke; production replaces this from rag.yaml in 3.5
# ---------------------------------------------------------------------------
@@ -382,6 +420,11 @@ def build_app(
scim_enabled: bool = False,
scim_tokens: dict[str, str] | None = None,
sso_enabled: bool = False,
+ compliance_enabled: bool = False,
+ compliance_region: str = "",
+ retention_policy: Any | None = None,
+ retention_enforcer: Any | None = None,
+ compliance_posture: Any | None = None,
enable_cors: bool = True,
default_tenant_id: TenantId | None = None,
) -> FastAPI:
@@ -441,6 +484,13 @@ def build_app(
"create / update / deactivate (per-tenant bearer token)."
),
},
+ {
+ "name": "compliance",
+ "description": (
+ "Compliance posture — GDPR right-to-erasure of the calling "
+ "tenant's data (data retention + residency configure via rag.yaml)."
+ ),
+ },
],
)
@@ -635,6 +685,40 @@ def build_app(
app.state.feedback_recorder = feedback_recorder
app.state.feedback_store = feedback_store
+ # Compliance posture (Step 6.10) — the RetentionEnforcer (GDPR right-to-erasure
+ # via POST /v1/compliance/erase + the operator-run retention sweep) over the
+ # wired feedback / provenance stores; the deployment data region for the ingest
+ # residency guard; and the live posture for GET /v1/status/compliance (the
+ # SOC 2 / GDPR control mapping). ``compliance_enabled`` gates residency
+ # enforcement; build_app_from_config builds the posture from cfg.compliance.
+ from rag_compliance import RetentionEnforcer
+ from rag_compliance import compliance_posture as _build_posture
+ from rag_core.types import RetentionPolicy
+
+ app.state.compliance_enabled = compliance_enabled
+ app.state.compliance_region = compliance_region
+ app.state.retention_enforcer = retention_enforcer or RetentionEnforcer(
+ feedback_store=app.state.feedback_store,
+ provenance_store=app.state.provenance_store,
+ )
+ app.state.compliance_posture = compliance_posture or _build_posture(
+ region=compliance_region,
+ controls={
+ "audit": audit_enabled,
+ "acl": acl_enabled,
+ "pii": pii_enabled,
+ "byok": app.state.kms_enabled,
+ "sso": sso_enabled,
+ "scim": scim_enabled,
+ "quotas": quota_enforcer is not None,
+ "guard": guard_enabled,
+ "breakers": breaker_registry is not None,
+ "retention": compliance_enabled,
+ "residency": compliance_enabled and bool(compliance_region),
+ },
+ retention=retention_policy or RetentionPolicy(),
+ )
+
# Drift monitors (Step 5.5) — when a registry is wired, the query path feeds
# the PSI monitors (query length / retrieval score / embedding norm), the
# feedback path feeds citation-clickthrough, and ``GET /v1/status/drift``
@@ -796,6 +880,10 @@ async def ingest_document(
),
)
+ # GDPR data-residency (Step 6.10) — refuse ingest when the tenant's required
+ # region does not match this deployment's region (no-op unless enabled).
+ _enforce_residency(request.app.state, tenant_id)
+
# Storage quota (Step 4.5) — admit-or-reject the upload against the
# tenant's storage-bytes gauge before the pipeline runs. A successful
# admission advances the gauge by the upload size.
@@ -847,6 +935,7 @@ async def ingest_document(
app.include_router(make_status_router())
app.include_router(make_audit_router())
app.include_router(make_scim_router())
+ app.include_router(make_compliance_router())
return app
diff --git a/apps/gateway/src/rag_gateway/compliance.py b/apps/gateway/src/rag_gateway/compliance.py
new file mode 100644
index 0000000..a73ca0e
--- /dev/null
+++ b/apps/gateway/src/rag_gateway/compliance.py
@@ -0,0 +1,58 @@
+"""Compliance surface — ``POST /v1/compliance/erase`` (GDPR right-to-erasure, Step 6.10).
+
+The erasure endpoint is tenant-scoped: it erases the **calling tenant's** data
+across the retention enforcer's wired stores. It is always available (a tenant
+can always request erasure of its own data — the GDPR posture), but defaults to a
+**dry-run preview**: actually deleting requires both ``dry_run=false`` *and*
+``confirm=true``, so an erasure is never a one-flag accident. The read-only
+posture (``GET /v1/status/compliance``) lives in the status router.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Request
+from rag_core.errors import AuthError
+from rag_core.gateway_types import ComplianceEraseRequest, GatewayError
+from rag_core.types import ErasureResult, RequestContext
+
+__all__ = ["make_compliance_router"]
+
+
+def _require_ctx(request: Request) -> RequestContext:
+ gateway = getattr(request.state, "gateway", None)
+ if gateway is None:
+ raise AuthError("request did not pass through the gateway middleware")
+ if gateway.auth_error is not None:
+ raise gateway.auth_error
+ ctx: RequestContext | None = gateway.ctx
+ if ctx is None:
+ raise AuthError("missing tenant / principal credentials")
+ return ctx
+
+
+def make_compliance_router() -> APIRouter:
+ """Build the compliance router (Step 6.10)."""
+ router = APIRouter(tags=["compliance"])
+
+ @router.post(
+ "/v1/compliance/erase",
+ response_model=ErasureResult,
+ responses={401: {"model": GatewayError, "description": "Missing or invalid auth"}},
+ summary="Erase the calling tenant's data — GDPR right-to-erasure (Step 6.10)",
+ )
+ async def erase(request: Request, body: ComplianceEraseRequest) -> ErasureResult:
+ """Erase (or, by default, preview) the calling tenant's data across all stores.
+
+ Returns an :class:`ErasureResult` with per-data-class counts. Deletes only
+ when ``dry_run=false`` **and** ``confirm=true``; otherwise it is a counting
+ preview (``dry_run=true`` in the result).
+ """
+ ctx = _require_ctx(request)
+ enforcer: Any = request.app.state.retention_enforcer
+ do_delete = (not body.dry_run) and body.confirm
+ result: ErasureResult = await enforcer.erase_tenant(ctx, dry_run=not do_delete)
+ return result
+
+ return router
diff --git a/apps/gateway/src/rag_gateway/query.py b/apps/gateway/src/rag_gateway/query.py
index 017827f..93b32eb 100644
--- a/apps/gateway/src/rag_gateway/query.py
+++ b/apps/gateway/src/rag_gateway/query.py
@@ -36,9 +36,11 @@
ACLDeniedError,
AuditNotFoundError,
AuthError,
+ ComplianceError,
ProvenanceNotFoundError,
RagError,
RateLimitError,
+ ResidencyViolationError,
RetrievalError,
ScimConflictError,
ScimError,
@@ -98,6 +100,9 @@ def _http_status_for(exc: BaseException) -> int:
:class:`~rag_core.errors.RagError` subclasses get specific
statuses. Anything else falls through to 500.
"""
+ if isinstance(exc, ResidencyViolationError):
+ # GDPR data-residency: tenant region != deployment region (Step 6.10).
+ return 403
if isinstance(exc, ACLDeniedError):
return 403
if isinstance(exc, AuthError):
@@ -119,6 +124,9 @@ def _http_status_for(exc: BaseException) -> int:
if isinstance(exc, ScimError):
# Other SCIM provisioning failure (e.g. unsupported filter) (Step 6.8).
return 400
+ if isinstance(exc, ComplianceError):
+ # Compliance-posture operation failure (Step 6.10); residency is 403 above.
+ return 400
if isinstance(exc, RetrievalError):
# Retrieval errors are bad-gateway because they indicate a
# downstream backend failure, not bad input.
diff --git a/apps/gateway/src/rag_gateway/status.py b/apps/gateway/src/rag_gateway/status.py
index 544b8d7..97088cf 100644
--- a/apps/gateway/src/rag_gateway/status.py
+++ b/apps/gateway/src/rag_gateway/status.py
@@ -64,6 +64,7 @@
from rag_core.types import (
BreakerSnapshot,
CircuitState,
+ CompliancePosture,
DriftMetric,
DriftReport,
DriftSnapshot,
@@ -938,6 +939,19 @@ async def status_sso(request: Request, tenant_id: str | None = None) -> SsoStatu
),
)
+ @router.get("/v1/status/compliance", response_model=CompliancePosture)
+ async def status_compliance(request: Request) -> CompliancePosture:
+ """The live compliance posture (Step 6.10).
+
+ Reports which governance controls are enabled (audit / ACL / PII / BYOK /
+ SSO / SCIM / quotas / retention / residency), the deployment's data region,
+ and the retention windows — the machine-readable backing for the SOC 2 /
+ GDPR control-mapping docs. Carries only configuration booleans, no tenant
+ data, so it is safe on the status surface.
+ """
+ posture = getattr(request.app.state, "compliance_posture", None)
+ return posture if posture is not None else CompliancePosture()
+
@router.websocket("/v1/status/ws")
async def status_ws(websocket: WebSocket) -> None:
"""Push health + metrics snapshots until the client disconnects.
diff --git a/apps/gateway/src/rag_gateway/wiring.py b/apps/gateway/src/rag_gateway/wiring.py
index 18b7684..f3486fe 100644
--- a/apps/gateway/src/rag_gateway/wiring.py
+++ b/apps/gateway/src/rag_gateway/wiring.py
@@ -849,11 +849,30 @@ def build_app_from_config(cfg: RagConfig, **overrides: Any) -> FastAPI:
scim_enabled = overrides.pop("scim_enabled", cfg.scim.enabled)
scim_tokens = overrides.pop("scim_tokens", dict(cfg.scim.tokens))
+ # Compliance posture (Step 6.10) — global retention windows + data region.
+ # ``build_app`` derives the live CompliancePosture from the control flags it
+ # already receives (audit / acl / pii / kms / sso / scim / quotas), so only the
+ # compliance-specific knobs are threaded here. Residency enforcement (ingest)
+ # is gated on ``cfg.compliance.enabled``.
+ from rag_core.types import RetentionPolicy
+
+ _r = cfg.compliance.retention
+ retention_policy = RetentionPolicy(
+ chunks_days=_r.chunks_days,
+ feedback_days=_r.feedback_days,
+ provenance_days=_r.provenance_days,
+ traces_days=_r.traces_days,
+ audit_days=_r.audit_days,
+ )
+
return build_app(
auth=auth_backend,
sso_enabled=cfg.sso.enabled,
scim_enabled=scim_enabled,
scim_tokens=scim_tokens,
+ compliance_enabled=cfg.compliance.enabled,
+ compliance_region=cfg.compliance.region,
+ retention_policy=retention_policy,
corpus_store=corpus_store,
retrieval_router=retrieval_router,
corpus_router=corpus_router,
diff --git a/apps/gateway/tests/test_compliance_routes.py b/apps/gateway/tests/test_compliance_routes.py
new file mode 100644
index 0000000..9be00f6
--- /dev/null
+++ b/apps/gateway/tests/test_compliance_routes.py
@@ -0,0 +1,98 @@
+"""Tests for the gateway compliance surface (Step 6.10).
+
+Covers ``GET /v1/status/compliance`` (the live posture), ``POST /v1/compliance/
+erase`` (GDPR right-to-erasure — dry-run default + confirmed delete), and the
+data-residency guard at ingest (refuses a tenant whose region differs).
+"""
+
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+from rag_config.schema import RagConfig
+from rag_gateway.wiring import build_app_from_config
+
+
+def _client() -> TestClient:
+ cfg = RagConfig.model_validate(
+ {
+ "version": "1",
+ "compliance": {
+ "enabled": True,
+ "region": "eu-west-1",
+ "retention": {"feedback_days": 30, "provenance_days": 90},
+ },
+ "audit": {"enabled": True},
+ "acl": {"enabled": True},
+ "tenants": [
+ {"id": "acme", "name": "Acme", "data_region": "eu-west-1"},
+ {"id": "globex", "name": "Globex", "data_region": "us-east-1"},
+ ],
+ }
+ )
+ return TestClient(build_app_from_config(cfg))
+
+
+def test_status_compliance_posture() -> None:
+ body = _client().get("/v1/status/compliance").json()
+ assert body["region"] == "eu-west-1"
+ assert "audit" in body["enabled_controls"]
+ assert "acl" in body["enabled_controls"]
+ assert "residency" in body["enabled_controls"]
+ assert body["retention"]["feedback_days"] == 30
+
+
+def test_erase_defaults_to_dry_run() -> None:
+ client = _client()
+ h = {"X-Tenant-Id": "acme", "X-Principal-Id": "alice"}
+ r = client.post("/v1/compliance/erase", json={}, headers=h)
+ assert r.status_code == 200
+ body = r.json()
+ assert body["dry_run"] is True
+ assert body["scope"] == "erasure"
+ assert body["tenant_id"] == "acme"
+
+
+def test_erase_requires_confirm_to_delete() -> None:
+ client = _client()
+ h = {"X-Tenant-Id": "acme", "X-Principal-Id": "alice"}
+ # dry_run=false but confirm=false → still a preview (safety).
+ r = client.post("/v1/compliance/erase", json={"dry_run": False, "confirm": False}, headers=h)
+ assert r.json()["dry_run"] is True
+ # dry_run=false AND confirm=true → real delete.
+ r2 = client.post("/v1/compliance/erase", json={"dry_run": False, "confirm": True}, headers=h)
+ assert r2.json()["dry_run"] is False
+
+
+def test_erase_requires_auth() -> None:
+ assert _client().post("/v1/compliance/erase", json={}).status_code == 401
+
+
+def test_residency_blocks_mismatched_region() -> None:
+ client = _client()
+ files = {"file": ("a.txt", b"hello", "text/plain")}
+ # acme (eu) on an eu deployment → residency OK (ingest proceeds).
+ ok = client.post(
+ "/v1/ingest/document",
+ data={"tenant_id": "acme", "corpus_id": "c", "principal_id": "p"},
+ files=files,
+ )
+ assert ok.status_code != 403
+ # globex (us) on an eu deployment → residency violation.
+ blocked = client.post(
+ "/v1/ingest/document",
+ data={"tenant_id": "globex", "corpus_id": "c", "principal_id": "p"},
+ files=files,
+ )
+ assert blocked.status_code == 403
+ assert blocked.json()["error"]["code"] == "residency_violation"
+
+
+def test_residency_off_by_default() -> None:
+ # A gateway without cfg.compliance.enabled never blocks on residency.
+ plain = TestClient(build_app_from_config(RagConfig.model_validate({"version": "1"})))
+ r = plain.post(
+ "/v1/ingest/document",
+ data={"tenant_id": "anyone", "corpus_id": "c", "principal_id": "p"},
+ files={"file": ("a.txt", b"hello", "text/plain")},
+ )
+ assert r.status_code != 403
diff --git a/dist/openapi.json b/dist/openapi.json
index da8e898..82f9b45 100644
--- a/dist/openapi.json
+++ b/dist/openapi.json
@@ -1130,6 +1130,57 @@
"title": "ClaimVerdict",
"type": "object"
},
+ "ComplianceEraseRequest": {
+ "description": "``POST /v1/compliance/erase`` body — GDPR right-to-erasure (Step 6.10).\n\nErases the **calling tenant's** data across the retention-enforcer's wired\nstores (tenant scope comes from the authenticated principal, never the body).\n``dry_run`` defaults to ``True`` (a safe preview that *counts* without\ndeleting); set it ``false`` **and** ``confirm`` ``true`` to actually delete —\nboth are required so an erasure is never a one-flag accident.",
+ "properties": {
+ "confirm": {
+ "default": false,
+ "title": "Confirm",
+ "type": "boolean"
+ },
+ "dry_run": {
+ "default": true,
+ "title": "Dry Run",
+ "type": "boolean"
+ }
+ },
+ "title": "ComplianceEraseRequest",
+ "type": "object"
+ },
+ "CompliancePosture": {
+ "description": "The platform's live compliance posture (Step 6.10).\n\nA point-in-time view of which governance controls are enabled, the deployment's\ndata region (GDPR residency), and the retention windows — computed from ``cfg``\nby ``rag_compliance.compliance_posture`` and served by\n``GET /v1/status/compliance`` + ``ragctl compliance report``. It is the\nmachine-readable backing for the SOC 2 / GDPR control-mapping docs: each control\nmaps to a Trust Service Criterion, and this reports whether it is on in *this*\ndeployment. Carries no tenant data — only configuration booleans — so it is\nsafe on the status surface.",
+ "properties": {
+ "controls": {
+ "additionalProperties": {
+ "type": "boolean"
+ },
+ "title": "Controls",
+ "type": "object"
+ },
+ "enabled_controls": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Enabled Controls",
+ "type": "array"
+ },
+ "generated_at": {
+ "format": "date-time",
+ "title": "Generated At",
+ "type": "string"
+ },
+ "region": {
+ "default": "",
+ "title": "Region",
+ "type": "string"
+ },
+ "retention": {
+ "$ref": "#/components/schemas/RetentionPolicy"
+ }
+ },
+ "title": "CompliancePosture",
+ "type": "object"
+ },
"ComponentHealth": {
"description": "Liveness of one gateway component.",
"properties": {
@@ -1868,6 +1919,47 @@
"title": "EmbeddingsUsage",
"type": "object"
},
+ "ErasureResult": {
+ "description": "Outcome of a retention purge or a GDPR right-to-erasure run (Step 6.10).\n\n``purged`` maps each :class:`DataClass` (by value) to the number of records\nremoved; ``total`` is their sum. ``dry_run`` is ``True`` when the run only\n*counted* what would be purged without deleting (the default for a preview).",
+ "properties": {
+ "dry_run": {
+ "default": false,
+ "title": "Dry Run",
+ "type": "boolean"
+ },
+ "generated_at": {
+ "format": "date-time",
+ "title": "Generated At",
+ "type": "string"
+ },
+ "purged": {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "title": "Purged",
+ "type": "object"
+ },
+ "scope": {
+ "default": "retention",
+ "title": "Scope",
+ "type": "string"
+ },
+ "tenant_id": {
+ "title": "Tenant Id",
+ "type": "string"
+ },
+ "total": {
+ "default": 0,
+ "title": "Total",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "tenant_id"
+ ],
+ "title": "ErasureResult",
+ "type": "object"
+ },
"ExperimentAssignment": {
"description": "Which A/B variant served this response (Step 5.7c).\n\nPresent on a :class:`QueryResponse` / :class:`RetrieveResponse` only when\nA/B *routing* is active and assigned the request to an experiment — the\ndeterministic, ``request_id``-hashed slice that can actually change which\nretrieval config a user gets (shadow mode, Step 5.7b, never tags a response).\n``None`` otherwise (routing off, or the request was not in the experiment).\n\nCarries no query or answer text — only the experiment id and the assigned\nvariant label, so the tag is safe to log and surface. ``is_candidate``\nflags whether the *candidate* (alternative) config served the response, so a\nconsumer can split traffic without re-deriving it from the variant name.",
"properties": {
@@ -3526,6 +3618,68 @@
"title": "RagOptions",
"type": "object"
},
+ "RetentionPolicy": {
+ "description": "Per-data-class retention windows in days (Step 6.10).\n\n``None`` on a class means **retain indefinitely** (no purge for that class).\nThe runtime counterpart of ``cfg.compliance.retention`` — resolved per tenant\nat the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` is\nadvisory: the tamper-evident hash chain (Step 6.6) is not purged in place —\naudit retention is the WORM **export** lifecycle — so the enforcer never drops\naudit events, it only reports the configured window.",
+ "properties": {
+ "audit_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Audit Days"
+ },
+ "chunks_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Chunks Days"
+ },
+ "feedback_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Feedback Days"
+ },
+ "provenance_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Provenance Days"
+ },
+ "traces_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Traces Days"
+ }
+ },
+ "title": "RetentionPolicy",
+ "type": "object"
+ },
"RetrieveRequest": {
"description": "Full ``POST /v1/retrieve`` body — retrieval-only.\n\nSame identifying triple + corpus / top_k / filters as\n:class:`QueryRequest`, but no rerank / pack / generate fields:\nthis endpoint is for callers (other services, custom rerankers)\nthat want only the router's ChunkRef output.",
"properties": {
@@ -5597,6 +5751,58 @@
]
}
},
+ "/v1/compliance/erase": {
+ "post": {
+ "description": "Erase (or, by default, preview) the calling tenant's data across all stores.\n\nReturns an :class:`ErasureResult` with per-data-class counts. Deletes only\nwhen ``dry_run=false`` **and** ``confirm=true``; otherwise it is a counting\npreview (``dry_run=true`` in the result).",
+ "operationId": "erase_v1_compliance_erase_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ComplianceEraseRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErasureResult"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "401": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GatewayError"
+ }
+ }
+ },
+ "description": "Missing or invalid auth"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Erase the calling tenant's data — GDPR right-to-erasure (Step 6.10)",
+ "tags": [
+ "compliance"
+ ]
+ }
+ },
"/v1/connectors/status": {
"get": {
"operationId": "connectors_status_v1_connectors_status_get",
@@ -6215,6 +6421,28 @@
]
}
},
+ "/v1/status/compliance": {
+ "get": {
+ "description": "The live compliance posture (Step 6.10).\n\nReports which governance controls are enabled (audit / ACL / PII / BYOK /\nSSO / SCIM / quotas / retention / residency), the deployment's data region,\nand the retention windows — the machine-readable backing for the SOC 2 /\nGDPR control-mapping docs. Carries only configuration booleans, no tenant\ndata, so it is safe on the status surface.",
+ "operationId": "status_compliance_v1_status_compliance_get",
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CompliancePosture"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "summary": "Status Compliance",
+ "tags": [
+ "status"
+ ]
+ }
+ },
"/v1/status/cost": {
"get": {
"description": "One tenant's recent-spend anomaly verdict (Step 5.6c).\n\nTenant resolves from the ``tenant_id`` query param, else the\n``X-Tenant-Id`` header, else ``\"default\"``. Returns\n``insufficient_data`` when cost tracking is disabled or the tenant has\nseen too few requests. Detection only — never rejects a request.",
@@ -7138,6 +7366,10 @@
{
"description": "SCIM 2.0 directory provisioning — IdP-driven user / group create / update / deactivate (per-tenant bearer token).",
"name": "scim"
+ },
+ {
+ "description": "Compliance posture — GDPR right-to-erasure of the calling tenant's data (data retention + residency configure via rag.yaml).",
+ "name": "compliance"
}
]
}
diff --git a/dist/openapi.yaml b/dist/openapi.yaml
index 981ce8c..794db52 100644
--- a/dist/openapi.yaml
+++ b/dist/openapi.yaml
@@ -972,6 +972,75 @@ components:
- supported
title: ClaimVerdict
type: object
+ ComplianceEraseRequest:
+ description: '``POST /v1/compliance/erase`` body — GDPR right-to-erasure (Step
+ 6.10).
+
+
+ Erases the **calling tenant''s** data across the retention-enforcer''s wired
+
+ stores (tenant scope comes from the authenticated principal, never the body).
+
+ ``dry_run`` defaults to ``True`` (a safe preview that *counts* without
+
+ deleting); set it ``false`` **and** ``confirm`` ``true`` to actually delete
+ —
+
+ both are required so an erasure is never a one-flag accident.'
+ properties:
+ confirm:
+ default: false
+ title: Confirm
+ type: boolean
+ dry_run:
+ default: true
+ title: Dry Run
+ type: boolean
+ title: ComplianceEraseRequest
+ type: object
+ CompliancePosture:
+ description: 'The platform''s live compliance posture (Step 6.10).
+
+
+ A point-in-time view of which governance controls are enabled, the deployment''s
+
+ data region (GDPR residency), and the retention windows — computed from ``cfg``
+
+ by ``rag_compliance.compliance_posture`` and served by
+
+ ``GET /v1/status/compliance`` + ``ragctl compliance report``. It is the
+
+ machine-readable backing for the SOC 2 / GDPR control-mapping docs: each control
+
+ maps to a Trust Service Criterion, and this reports whether it is on in *this*
+
+ deployment. Carries no tenant data — only configuration booleans — so it
+ is
+
+ safe on the status surface.'
+ properties:
+ controls:
+ additionalProperties:
+ type: boolean
+ title: Controls
+ type: object
+ enabled_controls:
+ items:
+ type: string
+ title: Enabled Controls
+ type: array
+ generated_at:
+ format: date-time
+ title: Generated At
+ type: string
+ region:
+ default: ''
+ title: Region
+ type: string
+ retention:
+ $ref: '#/components/schemas/RetentionPolicy'
+ title: CompliancePosture
+ type: object
ComponentHealth:
description: Liveness of one gateway component.
properties:
@@ -1639,6 +1708,45 @@ components:
type: integer
title: EmbeddingsUsage
type: object
+ ErasureResult:
+ description: 'Outcome of a retention purge or a GDPR right-to-erasure run (Step
+ 6.10).
+
+
+ ``purged`` maps each :class:`DataClass` (by value) to the number of records
+
+ removed; ``total`` is their sum. ``dry_run`` is ``True`` when the run only
+
+ *counted* what would be purged without deleting (the default for a preview).'
+ properties:
+ dry_run:
+ default: false
+ title: Dry Run
+ type: boolean
+ generated_at:
+ format: date-time
+ title: Generated At
+ type: string
+ purged:
+ additionalProperties:
+ type: integer
+ title: Purged
+ type: object
+ scope:
+ default: retention
+ title: Scope
+ type: string
+ tenant_id:
+ title: Tenant Id
+ type: string
+ total:
+ default: 0
+ title: Total
+ type: integer
+ required:
+ - tenant_id
+ title: ErasureResult
+ type: object
ExperimentAssignment:
description: 'Which A/B variant served this response (Step 5.7c).
@@ -3125,6 +3233,51 @@ components:
type: integer
title: RagOptions
type: object
+ RetentionPolicy:
+ description: 'Per-data-class retention windows in days (Step 6.10).
+
+
+ ``None`` on a class means **retain indefinitely** (no purge for that class).
+
+ The runtime counterpart of ``cfg.compliance.retention`` — resolved per tenant
+
+ at the boundary and consumed by the ``RetentionEnforcer``. ``audit_days``
+ is
+
+ advisory: the tamper-evident hash chain (Step 6.6) is not purged in place
+ —
+
+ audit retention is the WORM **export** lifecycle — so the enforcer never drops
+
+ audit events, it only reports the configured window.'
+ properties:
+ audit_days:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Audit Days
+ chunks_days:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Chunks Days
+ feedback_days:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Feedback Days
+ provenance_days:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Provenance Days
+ traces_days:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Traces Days
+ title: RetentionPolicy
+ type: object
RetrieveRequest:
description: 'Full ``POST /v1/retrieve`` body — retrieval-only.
@@ -4645,6 +4798,46 @@ paths:
summary: Create a chat completion with retrieval pre-fetch (OpenAI-compatible)
tags:
- openai
+ /v1/compliance/erase:
+ post:
+ description: 'Erase (or, by default, preview) the calling tenant''s data across
+ all stores.
+
+
+ Returns an :class:`ErasureResult` with per-data-class counts. Deletes only
+
+ when ``dry_run=false`` **and** ``confirm=true``; otherwise it is a counting
+
+ preview (``dry_run=true`` in the result).'
+ operationId: erase_v1_compliance_erase_post
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ComplianceEraseRequest'
+ required: true
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErasureResult'
+ description: Successful Response
+ '401':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/GatewayError'
+ description: Missing or invalid auth
+ '422':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ description: Validation Error
+ summary: Erase the calling tenant's data — GDPR right-to-erasure (Step 6.10)
+ tags:
+ - compliance
/v1/connectors/status:
get:
operationId: connectors_status_v1_connectors_status_get
@@ -5086,6 +5279,31 @@ paths:
summary: Force Close Breaker
tags:
- status
+ /v1/status/compliance:
+ get:
+ description: 'The live compliance posture (Step 6.10).
+
+
+ Reports which governance controls are enabled (audit / ACL / PII / BYOK /
+
+ SSO / SCIM / quotas / retention / residency), the deployment''s data region,
+
+ and the retention windows — the machine-readable backing for the SOC 2 /
+
+ GDPR control-mapping docs. Carries only configuration booleans, no tenant
+
+ data, so it is safe on the status surface.'
+ operationId: status_compliance_v1_status_compliance_get
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CompliancePosture'
+ description: Successful Response
+ summary: Status Compliance
+ tags:
+ - status
/v1/status/cost:
get:
description: 'One tenant''s recent-spend anomaly verdict (Step 5.6c).
@@ -5712,3 +5930,6 @@ tags:
- description: SCIM 2.0 directory provisioning — IdP-driven user / group create /
update / deactivate (per-tenant bearer token).
name: scim
+- description: Compliance posture — GDPR right-to-erasure of the calling tenant's
+ data (data retention + residency configure via rag.yaml).
+ name: compliance
diff --git a/dist/rag.schema.json b/dist/rag.schema.json
index 3e3b070..1418a57 100644
--- a/dist/rag.schema.json
+++ b/dist/rag.schema.json
@@ -239,6 +239,27 @@
"title": "CandidateConfig",
"type": "object"
},
+ "ComplianceConfig": {
+ "additionalProperties": false,
+ "description": "Compliance posture \u2014 data retention + GDPR residency (Step 6.10).\n\nWhen ``enabled`` the gateway exposes ``GET /v1/status/compliance`` (the live\ncontrol posture backing the SOC 2 / GDPR mapping) and ``POST /v1/compliance/\nerase`` (tenant-scoped right-to-erasure), runs the ``RetentionEnforcer`` over\nthe wired stores, and enforces data-residency: a tenant whose\n``tenants[].data_region`` differs from ``region`` is refused at ingest\n(``ResidencyViolationError`` \u2192 HTTP 403).\n\n**Disabled by default** \u2014 residency enforcement can refuse ingest and erasure\ndeletes data, so it is opt-in. ``region`` is this deployment's declared data\nregion (e.g. ``eu-west-1``); leave it empty to declare no region (residency\nchecks then pass for every tenant).",
+ "properties": {
+ "enabled": {
+ "default": false,
+ "title": "Enabled",
+ "type": "boolean"
+ },
+ "region": {
+ "default": "",
+ "title": "Region",
+ "type": "string"
+ },
+ "retention": {
+ "$ref": "#/$defs/RetentionConfig"
+ }
+ },
+ "title": "ComplianceConfig",
+ "type": "object"
+ },
"ConfigVersion": {
"enum": [
"1"
@@ -1303,6 +1324,79 @@
"title": "RerankerProvider",
"type": "string"
},
+ "RetentionConfig": {
+ "additionalProperties": false,
+ "description": "Per-data-class retention windows in days (Step 6.10).\n\nEach field is the number of days to retain that data class; ``None`` (default)\nmeans **retain indefinitely**. Resolved into a ``rag_core.types.RetentionPolicy``\nand enforced by the ``RetentionEnforcer`` (purges expired feedback / provenance\n/ trace records). ``audit_days`` is advisory only \u2014 the tamper-evident audit\nchain (Step 6.6) is retained via its WORM **export**, never purged in place.\nA per-tenant ``tenants[].retention_days`` caps every class for that tenant.",
+ "properties": {
+ "chunks_days": {
+ "anyOf": [
+ {
+ "minimum": 1,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Chunks Days"
+ },
+ "feedback_days": {
+ "anyOf": [
+ {
+ "minimum": 1,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Feedback Days"
+ },
+ "provenance_days": {
+ "anyOf": [
+ {
+ "minimum": 1,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Provenance Days"
+ },
+ "traces_days": {
+ "anyOf": [
+ {
+ "minimum": 1,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Traces Days"
+ },
+ "audit_days": {
+ "anyOf": [
+ {
+ "minimum": 1,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Audit Days"
+ }
+ },
+ "title": "RetentionConfig",
+ "type": "object"
+ },
"RetrievalConfig": {
"additionalProperties": false,
"properties": {
@@ -1634,6 +1728,31 @@
}
],
"default": null
+ },
+ "data_region": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Data Region"
+ },
+ "retention_days": {
+ "anyOf": [
+ {
+ "minimum": 1,
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Retention Days"
}
},
"required": [
@@ -1943,6 +2062,9 @@
"scim": {
"$ref": "#/$defs/ScimConfig"
},
+ "compliance": {
+ "$ref": "#/$defs/ComplianceConfig"
+ },
"webhooks": {
"$ref": "#/$defs/WebhooksConfig"
},
diff --git a/dist/rag.schema.yaml b/dist/rag.schema.yaml
index 58d2996..390b6da 100644
--- a/dist/rag.schema.yaml
+++ b/dist/rag.schema.yaml
@@ -282,6 +282,44 @@ $defs:
type: number
title: CandidateConfig
type: object
+ ComplianceConfig:
+ additionalProperties: false
+ description: 'Compliance posture — data retention + GDPR residency (Step 6.10).
+
+
+ When ``enabled`` the gateway exposes ``GET /v1/status/compliance`` (the live
+
+ control posture backing the SOC 2 / GDPR mapping) and ``POST /v1/compliance/
+
+ erase`` (tenant-scoped right-to-erasure), runs the ``RetentionEnforcer`` over
+
+ the wired stores, and enforces data-residency: a tenant whose
+
+ ``tenants[].data_region`` differs from ``region`` is refused at ingest
+
+ (``ResidencyViolationError`` → HTTP 403).
+
+
+ **Disabled by default** — residency enforcement can refuse ingest and erasure
+
+ deletes data, so it is opt-in. ``region`` is this deployment''s declared data
+
+ region (e.g. ``eu-west-1``); leave it empty to declare no region (residency
+
+ checks then pass for every tenant).'
+ properties:
+ enabled:
+ default: false
+ title: Enabled
+ type: boolean
+ region:
+ default: ''
+ title: Region
+ type: string
+ retention:
+ $ref: '#/$defs/RetentionConfig'
+ title: ComplianceConfig
+ type: object
ConfigVersion:
enum:
- '1'
@@ -1335,6 +1373,60 @@ $defs:
- bge_api
title: RerankerProvider
type: string
+ RetentionConfig:
+ additionalProperties: false
+ description: 'Per-data-class retention windows in days (Step 6.10).
+
+
+ Each field is the number of days to retain that data class; ``None`` (default)
+
+ means **retain indefinitely**. Resolved into a ``rag_core.types.RetentionPolicy``
+
+ and enforced by the ``RetentionEnforcer`` (purges expired feedback / provenance
+
+ / trace records). ``audit_days`` is advisory only — the tamper-evident audit
+
+ chain (Step 6.6) is retained via its WORM **export**, never purged in place.
+
+ A per-tenant ``tenants[].retention_days`` caps every class for that tenant.'
+ properties:
+ chunks_days:
+ anyOf:
+ - minimum: 1
+ type: integer
+ - type: 'null'
+ default: null
+ title: Chunks Days
+ feedback_days:
+ anyOf:
+ - minimum: 1
+ type: integer
+ - type: 'null'
+ default: null
+ title: Feedback Days
+ provenance_days:
+ anyOf:
+ - minimum: 1
+ type: integer
+ - type: 'null'
+ default: null
+ title: Provenance Days
+ traces_days:
+ anyOf:
+ - minimum: 1
+ type: integer
+ - type: 'null'
+ default: null
+ title: Traces Days
+ audit_days:
+ anyOf:
+ - minimum: 1
+ type: integer
+ - type: 'null'
+ default: null
+ title: Audit Days
+ title: RetentionConfig
+ type: object
RetrievalConfig:
additionalProperties: false
properties:
@@ -1647,6 +1739,19 @@ $defs:
- $ref: '#/$defs/TenantSsoConfig'
- type: 'null'
default: null
+ data_region:
+ anyOf:
+ - type: string
+ - type: 'null'
+ default: null
+ title: Data Region
+ retention_days:
+ anyOf:
+ - minimum: 1
+ type: integer
+ - type: 'null'
+ default: null
+ title: Retention Days
required:
- id
- name
@@ -1890,6 +1995,8 @@ properties:
$ref: '#/$defs/SsoConfig'
scim:
$ref: '#/$defs/ScimConfig'
+ compliance:
+ $ref: '#/$defs/ComplianceConfig'
webhooks:
$ref: '#/$defs/WebhooksConfig'
provenance:
diff --git a/dist/schemas/ComplianceEraseRequest.json b/dist/schemas/ComplianceEraseRequest.json
new file mode 100644
index 0000000..d562b50
--- /dev/null
+++ b/dist/schemas/ComplianceEraseRequest.json
@@ -0,0 +1,17 @@
+{
+ "description": "``POST /v1/compliance/erase`` body \u2014 GDPR right-to-erasure (Step 6.10).\n\nErases the **calling tenant's** data across the retention-enforcer's wired\nstores (tenant scope comes from the authenticated principal, never the body).\n``dry_run`` defaults to ``True`` (a safe preview that *counts* without\ndeleting); set it ``false`` **and** ``confirm`` ``true`` to actually delete \u2014\nboth are required so an erasure is never a one-flag accident.",
+ "properties": {
+ "dry_run": {
+ "default": true,
+ "title": "Dry Run",
+ "type": "boolean"
+ },
+ "confirm": {
+ "default": false,
+ "title": "Confirm",
+ "type": "boolean"
+ }
+ },
+ "title": "ComplianceEraseRequest",
+ "type": "object"
+}
diff --git a/dist/schemas/CompliancePosture.json b/dist/schemas/CompliancePosture.json
new file mode 100644
index 0000000..a0cce47
--- /dev/null
+++ b/dist/schemas/CompliancePosture.json
@@ -0,0 +1,103 @@
+{
+ "$defs": {
+ "RetentionPolicy": {
+ "description": "Per-data-class retention windows in days (Step 6.10).\n\n``None`` on a class means **retain indefinitely** (no purge for that class).\nThe runtime counterpart of ``cfg.compliance.retention`` \u2014 resolved per tenant\nat the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` is\nadvisory: the tamper-evident hash chain (Step 6.6) is not purged in place \u2014\naudit retention is the WORM **export** lifecycle \u2014 so the enforcer never drops\naudit events, it only reports the configured window.",
+ "properties": {
+ "chunks_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Chunks Days"
+ },
+ "feedback_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Feedback Days"
+ },
+ "provenance_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Provenance Days"
+ },
+ "traces_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Traces Days"
+ },
+ "audit_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Audit Days"
+ }
+ },
+ "title": "RetentionPolicy",
+ "type": "object"
+ }
+ },
+ "description": "The platform's live compliance posture (Step 6.10).\n\nA point-in-time view of which governance controls are enabled, the deployment's\ndata region (GDPR residency), and the retention windows \u2014 computed from ``cfg``\nby ``rag_compliance.compliance_posture`` and served by\n``GET /v1/status/compliance`` + ``ragctl compliance report``. It is the\nmachine-readable backing for the SOC 2 / GDPR control-mapping docs: each control\nmaps to a Trust Service Criterion, and this reports whether it is on in *this*\ndeployment. Carries no tenant data \u2014 only configuration booleans \u2014 so it is\nsafe on the status surface.",
+ "properties": {
+ "region": {
+ "default": "",
+ "title": "Region",
+ "type": "string"
+ },
+ "controls": {
+ "additionalProperties": {
+ "type": "boolean"
+ },
+ "title": "Controls",
+ "type": "object"
+ },
+ "enabled_controls": {
+ "items": {
+ "type": "string"
+ },
+ "title": "Enabled Controls",
+ "type": "array"
+ },
+ "retention": {
+ "$ref": "#/$defs/RetentionPolicy"
+ },
+ "generated_at": {
+ "format": "date-time",
+ "title": "Generated At",
+ "type": "string"
+ }
+ },
+ "title": "CompliancePosture",
+ "type": "object"
+}
diff --git a/dist/schemas/ErasureResult.json b/dist/schemas/ErasureResult.json
new file mode 100644
index 0000000..c8c0aa1
--- /dev/null
+++ b/dist/schemas/ErasureResult.json
@@ -0,0 +1,41 @@
+{
+ "description": "Outcome of a retention purge or a GDPR right-to-erasure run (Step 6.10).\n\n``purged`` maps each :class:`DataClass` (by value) to the number of records\nremoved; ``total`` is their sum. ``dry_run`` is ``True`` when the run only\n*counted* what would be purged without deleting (the default for a preview).",
+ "properties": {
+ "tenant_id": {
+ "title": "Tenant Id",
+ "type": "string"
+ },
+ "scope": {
+ "default": "retention",
+ "title": "Scope",
+ "type": "string"
+ },
+ "purged": {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "title": "Purged",
+ "type": "object"
+ },
+ "total": {
+ "default": 0,
+ "title": "Total",
+ "type": "integer"
+ },
+ "dry_run": {
+ "default": false,
+ "title": "Dry Run",
+ "type": "boolean"
+ },
+ "generated_at": {
+ "format": "date-time",
+ "title": "Generated At",
+ "type": "string"
+ }
+ },
+ "required": [
+ "tenant_id"
+ ],
+ "title": "ErasureResult",
+ "type": "object"
+}
diff --git a/dist/schemas/RetentionPolicy.json b/dist/schemas/RetentionPolicy.json
new file mode 100644
index 0000000..d66082a
--- /dev/null
+++ b/dist/schemas/RetentionPolicy.json
@@ -0,0 +1,67 @@
+{
+ "description": "Per-data-class retention windows in days (Step 6.10).\n\n``None`` on a class means **retain indefinitely** (no purge for that class).\nThe runtime counterpart of ``cfg.compliance.retention`` \u2014 resolved per tenant\nat the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` is\nadvisory: the tamper-evident hash chain (Step 6.6) is not purged in place \u2014\naudit retention is the WORM **export** lifecycle \u2014 so the enforcer never drops\naudit events, it only reports the configured window.",
+ "properties": {
+ "chunks_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Chunks Days"
+ },
+ "feedback_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Feedback Days"
+ },
+ "provenance_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Provenance Days"
+ },
+ "traces_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Traces Days"
+ },
+ "audit_days": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Audit Days"
+ }
+ },
+ "title": "RetentionPolicy",
+ "type": "object"
+}
diff --git a/dist/schemas/TenantSettings.json b/dist/schemas/TenantSettings.json
index 55dc760..2050318 100644
--- a/dist/schemas/TenantSettings.json
+++ b/dist/schemas/TenantSettings.json
@@ -70,6 +70,18 @@
],
"default": null,
"title": "Physical Index"
+ },
+ "data_region": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "title": "Data Region"
}
},
"required": [
diff --git a/docs/README.md b/docs/README.md
index 7848aee..c15157d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,6 +13,7 @@
| [byok.md](architecture/byok.md) | BYOK / envelope encryption (Step 6.7): what's encrypted (chunk content at rest) vs not (embedding vectors — search needs plaintext); DEK+KEK envelope (client-side AES-GCM DEK, provider wraps the DEK); per-tenant isolation via KEK + `tenant_id` AAD; sealing as a typed error; rag-core/rag-backends split; slicing (6.7a library, 6.7b config + factory + AWS KMS, 6.7c GCP/Azure/Vault, 6.7d rotation) |
| [sso-scim.md](architecture/sso-scim.md) | SSO / SCIM (Step 6.8): OIDC + SAML federation + SCIM 2.0 provisioning + per-tenant IdP config. `FederatedAuth` *is* an `Auth` SPI backend (the `authenticate(token, tenant_id) → Principal` seam — no middleware change); group claims → `acl_labels` so Step 6.3/6.5 govern federated users; dependency-free defaults (stdlib HS256 JWT, `defusedxml` SAML) with asymmetric OIDC / XML-DSig behind `[oidc]` / `[saml]` extras; algorithm-allowlist (`alg:none`/downgrade defense); per-tenant IdP on `tenants[].sso`; SCIM is a separate surface with its own per-tenant bearer token + tenant-scoped `ScimStore`; PII-free `sso.*`/`scim.*` events (hashed subject); deferred (JWKS rotation, SP-initiated SAML, directory-backed deprovisioning) |
| [airgap-bundle.md](architecture/airgap-bundle.md) | Air-gapped install bundle (Step 6.9): one signed `.tar.gz` of all runtime images (`docker save`) + the packaged Helm chart + `rag.yaml` + a standalone installer, for networks with no registry / internet. Integrity = a standard `SHA256SUMS` (verifiable with `sha256sum -c`, no cosign/network) whose hash is pinned as `manifest.content_hash`, plus an optional cosign signature over it (the 6.6b content-hash + optional-signature pattern); the same `SHA256SUMS` drives the Python verifier and the standalone shell installer. Logic in `ragctl.airgap` (typed/tested, subprocess seam stubbable; `--dry-run` = verifiable bundle minus blobs); standalone `install.{sh,ps1}` need only docker+helm; digest-pinned manifest-driven image set; key-based cosign for air-gap, keyless for connected releases; deferred (ctr/podman load, registry re-tag/push, backend charts) |
+| [compliance.md](architecture/compliance.md) | Compliance posture (Step 6.10, the Phase-6 capstone): data retention + GDPR erasure/residency + the live control posture that backs the SOC 2 / GDPR mapping. New `rag-compliance` package (`RetentionEnforcer` + `compliance_posture` / `residency_ok`, config-free); retention is a non-abstract `purge_before` / `purge_tenant` *capability* on the Feedback/Provenance stores (`dry_run` in the SPI so a preview counts-without-deleting; audit chain never purged in place — its retention is the 6.6b WORM export); `POST /v1/compliance/erase` (tenant-scoped right-to-erasure, dry-run default + two-flag delete); per-tenant `data_region` enforced at ingest (`ResidencyViolationError` → 403); `GET /v1/status/compliance` posture computed from the control flags; PII-free `compliance.*` events; deferred (subject-level erasure, multi-region routing) |
| [caching.md](architecture/caching.md) | Three-cache split: `EmbeddingCache`, `RetrievalCache`, `AnswerCache` — distinct invalidation rules |
| [performance.md](architecture/performance.md) | Hot-path discipline, per-SPI p99 budgets, async telemetry, reviewer checklist |
| [pipeline-batcher.md](architecture/pipeline-batcher.md) | `Pipeline` (async DAG, bounded queues, per-stage workers) + `Batcher` (DataLoader-pattern coalescing) primitives — Step 1.1d |
@@ -112,6 +113,7 @@
| [encryption.md](reference/encryption.md) | BYOK envelope encryption (Step 6.7) — `KeyManager` SPI + `NoopKeyManager`; `EnvelopeKeyManager` (AES-256-GCM DEK + `tenant_id` AAD) + `LocalKeyManager` + cloud providers **`AwsKmsKeyManager`** / **`GcpKmsKeyManager`** / **`AzureKeyVaultKeyManager`** / **`VaultKeyManager`** (behind `[kms-*]` extras); `EncryptingStorage` decorator; `EncryptionError` / `KeyUnavailableError` (sealing); provider table; `cfg.kms` + `tenants[].kms_key_id` + `build_key_manager_from_config` factory; **`RotatingKeyManager`** + `RetiredKey` + `rewrap` (6.7d zero-downtime rotation); `ragctl kms [--rotate]` |
| [sso.md](reference/sso.md) | SSO / SCIM (Step 6.8, `rag_sso`) — `FederatedAuth` (`Auth` backend) + `OidcProvider`/`OidcSettings` + `SamlProvider`/`SamlSettings`/`signxml_verifier` + `identity_to_principal`; `ScimService`/`parse_eq_filter`; low-level `verify_jwt`/`encode_jwt_hs256` (stdlib HS256, `[oidc]` RS256); `ScimStore` SPI + `NoopScimStore`; `FederatedIdentity`/`SsoProtocol`/`ScimUser`/`ScimGroup` core types + `ScimListResponse`/`ScimPatchOp`/`ScimErrorBody`/`SsoStatusResponse` wire types; `SsoError`/`ScimError`/`ScimNotFoundError`/`ScimConflictError`; `/scim/v2/*` + `GET /v1/status/sso`; `cfg.sso`/`cfg.scim`/`tenants[].sso`; `sso.*`/`scim.*` events; `ragctl sso`/`ragctl scim`; extension points |
| [airgap.md](reference/airgap.md) | Air-gapped install bundle (Step 6.9) — `ragctl airgap build`/`inspect`/`verify`/`install`; `ragctl.airgap` models (`BundleManifest`/`BundleImage`/`BundleFile`/`BundleVerification`) + pure helpers (`parse_image_list`/`render_sha256sums`/`content_hash`/`build_manifest`/`verify_bundle`/`gateway_image_ref`) + the docker/helm/cosign seam (`assemble_bundle`/`save_images`/`package_chart`/`load_images`/`install_chart`/`cosign_*`); bundle layout + `SHA256SUMS`/`content_hash` integrity (+ `reason` codes); `infra/airgap/images.txt`; standalone `install.{sh,ps1}` (`--verify-only`); `--dry-run`; `task airgap:build`/`build-dry`/`verify`; `release-airgap.yml` |
+| [compliance.md](reference/compliance.md) | Compliance (Step 6.10, `rag_compliance`) — `RetentionEnforcer` (`purge` / `erase_tenant`, `dry_run`) + `compliance_posture` + `residency_ok`; `DataClass`/`RetentionPolicy`/`ErasureResult`/`CompliancePosture` core types; `ComplianceError`/`ResidencyViolationError`; `purge_before`/`purge_tenant` SPI methods; `GET /v1/status/compliance` + `POST /v1/compliance/erase`; `cfg.compliance` + `tenants[].data_region`/`retention_days`; `compliance.*` events; `ragctl compliance report`/`demo`; extension points |
| [webhooks.md](reference/webhooks.md) | Outbound webhooks (Step 3.9) — event catalogue (`ingest.completed` / `audit.policy_violation` / `drift.detected` / `eval.regression`), event envelope, HMAC signing + `verify()`, at-least-once delivery, `/v1/webhooks/subscriptions` CRUD + test, `rag.yaml` block, `ragctl webhooks demo`, internals + extension points |
| [integrations.md](reference/integrations.md) | Framework adapters (Step 3.8) — `agentcontextos.integrations.*` for LangChain / LlamaIndex / Haystack / DSPy / LangGraph / CrewAI / AutoGen / Semantic Kernel; per-framework extras, shared config + chunk metadata, usage per framework, internals + extension points |
| [status-api.md](reference/status-api.md) | Status & Metrics API (Step 3.11) — `/v1/status/health` / `metrics` / `logs` (+ SSE `logs/stream`), `WS /v1/status/ws`, `/v1/connectors/status`; metric catalogue + request-timing middleware, the `MetricsCollector` / `LogTail` read-side, CORS + query-param identity for browser streams, extension points |
@@ -184,6 +186,7 @@ broken, and what to fix before committing to the next phase.
| [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-0035-acl-pushdown.md](adr/ADR-0035-acl-pushdown.md) | Decision (Step 6.3): label-based ACL push-down at retrieval. `AclPolicyEngine` (a decorator like `QuotaPolicyEngine`) And-merges `any_in("acl_labels", principal.acl_labels)` into every `read_chunk` push-down at the canonical `HybridRetriever` PDP site — overlap semantics via the existing `AnyIn` predicate (zero backend/translator changes), **fail-closed** (label-less principal matches nothing; "public" = a shared label), **opt-in** via `cfg.acl.enabled`; emits `acl.egress_denied` on a request-level denial; graph edge ACLs + post-retrieval re-verification (6.4) deferred |
| [ADR-0036-acl-egress-verifier.md](adr/ADR-0036-acl-egress-verifier.md) | Decision (Step 6.4): a post-retrieval ACL re-check as a **defense-in-depth second layer** behind the 6.3 push-down. `AclEgressVerifier.verify(ctx, refs)` drops any returned `ChunkRef` whose labels don't overlap the principal's — same overlap semantics (no-op on correct results), reading `ChunkRef.acl_labels` (no re-hydration), **independent of the PDP** (consults only `ctx.principal.acl_labels`) so a push-down bug/bypass can't disable both; wired at the gateway as a `SupportsRoute` wrapper around `app.state.retrieval_router` (covers query/retrieve/corpus/OpenAI/agent); `cfg.acl.verify_egress` default on but gated by `enabled`; emits `acl.egress_violation` on a caught leak; a red-team gate proves a zero escaped-violation rate when the push-down is bypassed; backend-mislabel re-hydration + per-tenant violation metrics deferred |
+| [ADR-0042-compliance-posture.md](adr/ADR-0042-compliance-posture.md) | Decision (Step 6.10, Phase-6 capstone): add the three compliance pieces on top of the controls the platform already ships (audit/ACL/PII/BYOK/SSO/quotas). New `rag-compliance` package (config-free, like rag-feedback/rag-drift): `RetentionEnforcer` drives tenant-scoped `purge_*`; `compliance_posture`/`residency_ok` are pure. **Retention is a capability on the existing stores, not a new SPI** — non-abstract `purge_before`/`purge_tenant` (default no-op) on Feedback/Provenance, with `dry_run` in the SPI so a preview counts-without-deleting uniformly (ProvenanceStore has no `list`). **Audit is never purged in place** (the hash chain would break) — audit retention is the 6.6b WORM export; `audit_days` is advisory. **Right-to-erasure is always-on, tenant-self-service, two-flag** — `POST /v1/compliance/erase` erases the *calling* tenant's data (scope from the principal, never the body), dry-run by default, delete needs `dry_run=false` AND `confirm=true`. **Residency = declared per tenant + enforced at ingest** (`tenants[].data_region` vs `cfg.compliance.region` → 403), opt-in, a single-deployment assertion not multi-region routing. **The SOC 2 / GDPR mapping is a doc backed by a live posture** (`GET /v1/status/compliance` reports which controls are on, so the mapping is checkable). Deferred: subject-level (vs tenant-level) erasure, an admin retention-sweep endpoint, multi-region routing, automated audit-evidence bundles; rejected purging the audit chain, a `Purgeable` SPI mixin, a static doc with no live backing |
| [ADR-0041-airgap-bundle.md](adr/ADR-0041-airgap-bundle.md) | Decision (Step 6.9): ship the platform as one signed, self-contained offline bundle (all runtime images + Helm chart + config + installer). Integrity reuses the WORM-export pattern (6.6b): a standard `SHA256SUMS` whose hash is pinned as `manifest.content_hash` is the **hard gate** — verifiable with nothing but `sha256sum`, no network/cosign — and a cosign signature **over `SHA256SUMS`** adds authenticity; the *same* `SHA256SUMS` drives the Python verifier and the standalone shell installer so they can't diverge. The shell/pwsh `install.{sh,ps1}` (shipped inside the bundle) need only docker+helm (air-gap hosts lack uv/the workspace); `ragctl airgap` holds the typed/tested build+verify logic (pure core separated from a stubbable docker/helm/cosign subprocess seam; `--dry-run` = a verifiable bundle minus image blobs, so the path is testable with no Docker). Digest-pinned manifest-driven image set (`infra/airgap/images.txt` + the chart-derived gateway image); key-based cosign is the air-gap recommendation (keyless needs Rekor + an identity policy), keyless is the connected-release path (`release-airgap.yml` on tags). Deferred: ctr/podman load, registry re-tag/push, multi-arch selection, bundling backend charts, TUF-rooted offline keyless verify; rejected `oras`/OCI (no registry to pull from in an air-gap), a second HMAC scheme (cosign already the signer), a pure-shell build (would escape mypy/tests) |
| [ADR-0040-sso-scim.md](adr/ADR-0040-sso-scim.md) | Decision (Step 6.8): enterprise identity in two surfaces. **Federation** — `FederatedAuth` *is* an `Auth` SPI backend (the `authenticate(token, tenant_id) → Principal` seam already runs at the boundary, so wiring it is the whole integration — no middleware change); group claims → `acl_labels` so Step 6.3 push-down + 6.5 PII egress govern federated users unchanged (`authorize` stays a coarse allow — federation establishes *who*, the PDP decides *what*). Dependency-free defaults (stdlib HS256 JWT with full `exp`/`nbf`/`iss`/`aud` + constant-time compare; `defusedxml` SAML validating Issuer/Conditions/Audience) with asymmetric OIDC (PyJWT, `[oidc]`) + SAML XML-DSig (signxml, `[saml]`, injected verifier → fail-closed) behind extras; **algorithm-allowlist** designs out `alg:none`/RS↔HS confusion. Per-tenant IdP on `tenants[].sso` (reuses Step 6.1 config; no provider → bearer rejected, header-identity still works). **Provisioning** — SCIM 2.0 is a separate surface with its own per-tenant bearer token (`cfg.scim.tokens`, not a user JWT), a tenant-scoped `ScimStore` SPI (`NoopScimStore`) + `ScimService`, SCIM-shaped errors, disabled→404; no new governed SPI call (linter passes). PII-free `sso.*`/`scim.*` events (hashed subject, never email/userName). Deferred: JWKS rotation, SP-initiated SAML + metadata, SCIM bulk/`/Me`/ETag, directory-backed deprovisioning, admin-console card; rejected Authlib/python3-saml (heavy lxml/xmlsec on the default install), a dedicated SSO middleware, SCIM token on `TenantConfig` |
| [ADR-0037-pii-egress-policies.md](adr/ADR-0037-pii-egress-policies.md) | Decision (Step 6.5): per-tenant PII enforcement at egress via a `PiiPolicyEngine` `egress_text` decorator (mirrors `QuotaPolicyEngine` / `AclPolicyEngine`), living in `rag-pii` (gains a `rag-policy` dep, like `rag-quota`). Handles both subject shapes the gateway already passes — `list[Chunk]` context + `str` answer — so it plugs into the existing `egress_text` call sites with no route change; maps `ctx.pii_policy.action` allow→delegate / redact·mask→`transform` / block→`deny`, reusing the Step 1.7 detector + rewriters and the same `min_score`+`entities` filter (no-op on clean text); opt-in `cfg.pii.enabled` (injects `RegexPIIDetector`); PII-free `pii.egress_blocked` (block) / `pii.detected` (redact·mask); post-gen answer re-check for query/OpenAI/gRPC + citation egress deferred (stored chunks are ingest-sanitised) |
@@ -193,6 +196,13 @@ broken, and what to fix before committing to the next phase.
| [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 |
| [ADR-0028-ci-eval-gate.md](adr/ADR-0028-ci-eval-gate.md) | Decision (Step 5.3): gate PRs on the golden-set metrics vs a committed baseline — a compact byte-stable `EvalBaseline` (headline means + breakdowns, no `run_id` / `created_at` / `samples`) instead of the churny `report.json`; two checks (absolute floor + regression delta) both from `thresholds.yaml` `hard_gates`, the harness `--check` and the gate sharing one `load_gate_thresholds`; only recall/mrr/faithfulness gate (acl/pii are red-team's); standalone always-run `eval-gate.yml` for least-privilege `pull-requests: write` + a clean required check; one sticky marker comment posted even on failure, fork-PR tolerant; comparison in `rag_config.eval`, result types in `rag_core.eval` (additive, not in `dist/schemas`), runner in `eval/`; rejected folding into `ci.yml`/`ci-pass`, path-filtering the gate, `report.json` as baseline, floor-only, `pull_request_target` |
+## compliance/
+
+| File | Description |
+|------|-------------|
+| [soc2-control-mapping.md](compliance/soc2-control-mapping.md) | SOC 2 Type II — maps each AICPA Trust Service Criterion (CC / Availability / Processing Integrity / Confidentiality / Privacy) to the platform control that implements it (SSO, ACL, BYOK, audit, quotas, breakers, guard, retention, residency, …), the config that enables it, and the `GET /v1/status/compliance` posture key — so the mapping is checkable against the running config |
+| [gdpr-mapping.md](compliance/gdpr-mapping.md) | GDPR — article-by-article mapping (Art. 5 storage limitation → retention; Art. 17 erasure → `/v1/compliance/erase`; Art. 25 by-design → PII redaction at ingest; Art. 30 records → audit log; Art. 32 security → BYOK/ACL; Art. 44–50 transfers → data residency) + the erasure / residency flows + deferred caveats |
+
## research/
| File | Description |
diff --git a/docs/adr/ADR-0042-compliance-posture.md b/docs/adr/ADR-0042-compliance-posture.md
new file mode 100644
index 0000000..7221408
--- /dev/null
+++ b/docs/adr/ADR-0042-compliance-posture.md
@@ -0,0 +1,81 @@
+# ADR-0042 — Compliance posture: retention, GDPR erasure/residency, SOC 2 mapping
+
+**Status:** Accepted
+**Date:** 2026-06-08
+**Step:** 6.10 — Compliance posture (Phase 6 — Governance & Tenancy)
+**Related:** [ADR-0033](ADR-0033-logical-multi-tenancy.md) (per-tenant config), [ADR-0038](ADR-0038-immutable-audit-log.md) (audit WORM lifecycle), [architecture/compliance.md](../architecture/compliance.md), [reference/compliance.md](../reference/compliance.md), [compliance/soc2-control-mapping.md](../compliance/soc2-control-mapping.md)
+
+## Context
+
+The V1 plan (Step 6.10, the final Phase-6 step) calls for a **compliance posture**:
+SOC 2 Type II control mapping, GDPR **data-residency** config, and
+**data-retention** policies. The platform already ships the *controls* an auditor
+maps to (audit log, ACL, PII, BYOK, SSO, quotas, …); 6.10 adds the three missing
+pieces — retention/erasure mechanics, residency enforcement, and a
+machine-readable posture that backs the mapping doc.
+
+## Decision
+
+**1. A new `rag-compliance` package — retention enforcer + posture, config-free.**
+`RetentionEnforcer` drives tenant-scoped `purge_*` on the wired stores; `purge`
+applies per-class retention windows, `erase_tenant` is the GDPR right-to-erasure
+primitive. `compliance_posture` + `residency_ok` are pure. The package depends only
+on `rag-core` + `rag-observability` (it takes the resolved `RetentionPolicy` + a
+`RequestContext`, never `rag-config`), mirroring `rag-feedback` / `rag-drift`.
+
+**2. Retention is a *capability on the existing stores*, not a new SPI.** Added
+non-abstract `purge_before(ctx, cutoff, *, dry_run)` + `purge_tenant(ctx, *,
+dry_run)` to `FeedbackStore` + `ProvenanceStore` (default no-op, so no backend
+breaks). `dry_run` lives in the SPI so a preview *counts without deleting*
+uniformly — `ProvenanceStore` has no `list`, so a store-level dry-run is the only
+way to preview it. The audit chain is deliberately **not** purged in place (the
+hash chain would break) — audit retention is the Step 6.6b WORM **export**
+lifecycle; `RetentionPolicy.audit_days` is advisory.
+
+**3. Right-to-erasure is always-on + tenant-self-service; deletion is two-flag.**
+`POST /v1/compliance/erase` erases the *calling tenant's* data (scope from the
+authenticated principal, never the body) — a tenant can always request erasure of
+its own data (the GDPR posture). It defaults to a **dry-run preview**; actually
+deleting requires `dry_run=false` **and** `confirm=true`, so an erasure is never a
+one-flag accident.
+
+**4. Residency is declared per tenant + enforced at the ingest boundary.**
+`tenants[].data_region` (resolved onto `TenantSettings`, reusing the Step 6.1
+mechanism) vs `cfg.compliance.region`. A mismatch refuses ingest
+(`ResidencyViolationError` → 403, `compliance.residency_violation` event). It is
+**opt-in** (gated on `cfg.compliance.enabled`) and lenient when no region is
+declared — a single-deployment residency assertion, not multi-region routing.
+
+**5. The SOC 2 / GDPR mapping is a doc *backed by a live posture*.**
+`CompliancePosture` (controls + region + retention, computed from `cfg`) is served
+by `GET /v1/status/compliance` and `ragctl compliance report`. The mapping docs
+(`docs/compliance/*.md`) map each Trust Service Criterion / GDPR article to the
+platform control, and the posture reports whether that control is **on in this
+deployment** — so the mapping is verifiable, not just prose.
+
+## Consequences
+
+- New `rag-compliance` package; `DataClass` / `RetentionPolicy` / `ErasureResult`
+ / `CompliancePosture` core types + `ComplianceError` / `ResidencyViolationError`;
+ `purge_*` on Feedback/Provenance stores; `cfg.compliance` + `tenants[].data_region`
+ / `retention_days`; `GET /v1/status/compliance` + `POST /v1/compliance/erase`;
+ ingest residency guard; PII-free `compliance.*` events; `ragctl compliance
+ report` / `demo`; the SOC 2 + GDPR mapping docs.
+- ~30 tests; all gates green. **Phase 6 closes** (10/10).
+- **Deferred:** chunk/document purge by subject (needs an index-side
+ delete-by-document path), an admin retention-*sweep* endpoint (today the sweep
+ is `ragctl`/cron over the live stores; erasure is the API), multi-region data
+ routing, a per-control evidence-collection export, automated SOC 2 audit-evidence
+ bundles.
+
+## Alternatives considered
+
+- **A `Purgeable` SPI mixin / a new `RetentionStore` SPI.** Heavier than adding two
+ default methods to the two stores that actually hold expirable data; revisit if
+ many backends need bespoke retention.
+- **Purge the audit chain for retention.** Rejected — it breaks tamper-evidence;
+ the WORM export (6.6b) is the audit retention story.
+- **Residency as hard multi-region routing.** Out of scope for a single deployment;
+ the declared-region + ingest guard is the honest v1 (documented as such).
+- **A static SOC 2 doc with no live backing.** Rejected — a posture endpoint makes
+ the mapping checkable against the running config, which is what an auditor wants.
diff --git a/docs/architecture/compliance.md b/docs/architecture/compliance.md
new file mode 100644
index 0000000..bbefe5f
--- /dev/null
+++ b/docs/architecture/compliance.md
@@ -0,0 +1,112 @@
+# Compliance posture (Step 6.10)
+
+## Overview
+
+The final Phase-6 step adds the three things an enterprise / regulated buyer asks
+for on top of the controls the platform already ships:
+
+- **Data retention** — per-class windows + a purge mechanism.
+- **GDPR right-to-erasure** — erase a tenant's data on request.
+- **Data residency** — pin a tenant to a region, enforced at ingest.
+- **A live compliance posture** — the machine-readable backing for the **SOC 2 /
+ GDPR control-mapping** docs.
+
+It is the capstone of the governance phase: SSO/SCIM (6.8), BYOK (6.7), audit
+(6.6), PII (6.5), ACL (6.3–6.4), and tenancy (6.1–6.2) are the *controls*; 6.10
+adds retention/erasure/residency and a posture that reports them all.
+
+## Usage
+
+```yaml
+compliance:
+ enabled: true
+ region: eu-west-1
+ retention: { feedback_days: 30, provenance_days: 90 }
+tenants:
+ - { id: acme, name: Acme, data_region: eu-west-1 }
+```
+
+```bash
+ragctl compliance report -f rag.yaml # the posture
+curl -s $GATEWAY/v1/status/compliance | jq # same, live
+curl -X POST $GATEWAY/v1/compliance/erase -H "X-Tenant-Id: acme" \
+ -d '{"dry_run": false, "confirm": true}' # GDPR erasure
+```
+
+## Internals
+
+### `rag-compliance` package
+
+Mirrors `rag-feedback` / `rag-drift`: depends only on `rag-core` +
+`rag-observability` (no `rag-config`). Two pieces:
+
+- **`RetentionEnforcer`** — drives the tenant-scoped `purge_*` SPI methods. `purge`
+ applies a `RetentionPolicy` (per-class windows → cutoffs → `purge_before`);
+ `erase_tenant` calls `purge_tenant` (the GDPR primitive). Both take a
+ `RequestContext` (tenant scope) per call and support `dry_run`; both emit PII-free
+ `compliance.*` events.
+- **`compliance_posture` / `residency_ok`** — pure functions: assemble the
+ `CompliancePosture` from control booleans, and the data-residency check.
+
+### Retention as a store capability
+
+`purge_before(ctx, cutoff, *, dry_run)` + `purge_tenant(ctx, *, dry_run)` are
+**non-abstract** methods on `FeedbackStore` + `ProvenanceStore` (default no-op, so
+no backend breaks). `dry_run` is in the SPI so a preview *counts without deleting*
+uniformly — `ProvenanceStore` has no `list`, so a store-level dry-run is the only
+way to preview it. The in-memory stores delete from their `OrderedDict` by
+`(tenant, id)` key + record timestamp; cross-tenant purge is impossible (the tenant
+is part of the key).
+
+**Audit is not purged in place.** The Step 6.6 hash chain would break on deletion —
+audit retention is the WORM **export** lifecycle (6.6b); `RetentionPolicy.audit_days`
+is advisory and reported, never enforced by the purge.
+
+### Right-to-erasure (the endpoint)
+
+`POST /v1/compliance/erase` erases the **calling tenant's** data — the tenant scope
+comes from the authenticated principal, never the request body. It is always
+available (a tenant can always erase its own data) and defaults to a **dry-run
+preview**; deleting requires `dry_run=false` **and** `confirm=true`, so an erasure
+is never a one-flag accident.
+
+### Data residency (the guard)
+
+`tenants[].data_region` resolves onto `TenantSettings` (reusing the Step 6.1
+resolver). At `POST /v1/ingest/document`, `_enforce_residency` compares it against
+`cfg.compliance.region` via `residency_ok`; a mismatch raises
+`ResidencyViolationError` (→ 403) and emits `compliance.residency_violation`. It is
+opt-in (gated on `cfg.compliance.enabled`) and lenient when no region is declared —
+a single-deployment assertion + guard, **not** multi-region data routing.
+
+### The posture (the mapping backing)
+
+`build_app` derives a `CompliancePosture` from the control flags it already
+receives (audit / acl / pii / byok / sso / scim / quotas / guard / breakers /
+retention / residency) + the region + retention windows, and serves it at
+`GET /v1/status/compliance`. The SOC 2 / GDPR mapping docs map each criterion to a
+control; the posture reports whether that control is **on in this deployment** — so
+the mapping is checkable against the running config.
+
+```
+cfg.compliance ──build_app_from_config──▶ build_app
+ ├─ RetentionEnforcer(feedback_store, provenance_store) → POST /v1/compliance/erase + ragctl purge
+ ├─ compliance_region + compliance_enabled → ingest residency guard (403)
+ └─ CompliancePosture(controls, region, retention) → GET /v1/status/compliance
+```
+
+## Extension points
+
+- **Durable retention backend** — implement `purge_*` in a real store; conformance:
+ `tests/contract/test_retention_purge.py`.
+- **Subject-level erasure** — add an index-side delete-by-document path to erase a
+ specific subject's chunks (today erasure is tenant-level; deferred).
+- **Custom controls in the posture** — pass extra control booleans to
+ `compliance_posture`.
+
+## Boundaries / deferred
+
+Subject-level (vs tenant-level) erasure, an admin retention-*sweep* endpoint (today
+the sweep is `ragctl`/cron; erasure is the API), multi-region data routing, and
+automated SOC 2 audit-evidence bundles are deferred. See
+[ADR-0042](../adr/ADR-0042-compliance-posture.md).
diff --git a/docs/compliance/gdpr-mapping.md b/docs/compliance/gdpr-mapping.md
new file mode 100644
index 0000000..1fefad0
--- /dev/null
+++ b/docs/compliance/gdpr-mapping.md
@@ -0,0 +1,54 @@
+# GDPR — article mapping (Step 6.10)
+
+How AgentContextOS platform features support the relevant GDPR articles. Companion
+to the [SOC 2 control mapping](soc2-control-mapping.md) and the live posture at
+`GET /v1/status/compliance`.
+
+> **Scope.** The platform is a **processor**-side toolkit. Lawful basis, consent,
+> DPAs, and controller obligations are organisational and out of scope here; this
+> maps the **technical measures** GDPR Art. 24/25/32 expect.
+
+| Article | Requirement | Platform feature(s) | Enable via |
+|---------|-------------|---------------------|-----------|
+| **Art. 5(1)(e)** — Storage limitation | Keep personal data no longer than necessary | Per-class data-retention windows + scheduled purge | `cfg.compliance.retention` (Step 6.10) |
+| **Art. 5(1)(f)** / **Art. 32** — Integrity & confidentiality (security) | Encryption, access control, resilience | BYOK encryption at rest; ACL push-down + egress verifier; tenant isolation; circuit breakers | `cfg.kms` / `cfg.acl` / `cfg.breakers` (Steps 6.7 / 6.3–6.4 / 6.1–6.2 / 4.4) |
+| **Art. 15** — Right of access | Provide a copy / account of processing | Per-query signed **provenance** + the tamper-evident **audit log** | `cfg.provenance` / `cfg.audit` (Steps 5.1 / 6.6) |
+| **Art. 17** — Right to erasure ("right to be forgotten") | Erase a subject's data on request | `POST /v1/compliance/erase` (tenant-scoped) → `RetentionEnforcer.erase_tenant` purges across the wired stores | Step 6.10 |
+| **Art. 25** — Data protection by design & by default | Minimise + protect by default | PII detection + **redaction at ingest** (before vectors encode it); privacy-by-default hashing in provenance/feedback; controls off-by-default | Steps 1.7 / 5.1 / 5.4 |
+| **Art. 30** — Records of processing activities | Maintain processing records | Immutable hash-chain **audit log** + WORM signed export | `cfg.audit` (Step 6.6) |
+| **Art. 33/34** — Breach detection & notification (support) | Detect anomalies feeding breach response | Structured logging; drift + cost anomaly monitors; PII-free security events | Steps 0.7 / 5.5 / 5.6c |
+| **Art. 44–50** — International transfers | Keep data within an approved region | Per-tenant **data-residency** pinning enforced at ingest (`ResidencyViolationError`) | `cfg.compliance.region` + `tenants[].data_region` (Step 6.10) |
+
+## Right-to-erasure flow
+
+```bash
+# A tenant erases its own data (preview by default; confirm to delete):
+curl -X POST $GATEWAY/v1/compliance/erase \
+ -H "X-Tenant-Id: acme" -H "Authorization: Bearer $TOKEN" \
+ -d '{"dry_run": false, "confirm": true}'
+# → {"tenant_id":"acme","scope":"erasure","purged":{"feedback":N,"provenance":M},"total":...}
+```
+
+## Data-residency flow
+
+```yaml
+compliance:
+ enabled: true
+ region: eu-west-1 # this deployment serves eu-west-1
+tenants:
+ - id: acme
+ data_region: eu-west-1 # OK — ingest proceeds
+ - id: globex
+ data_region: us-east-1 # ingest refused (403 residency_violation)
+```
+
+## Caveats (deferred)
+
+- **Chunk/document-level erasure by subject** is not yet automatic — `erase_tenant`
+ purges the feedback / provenance stores; deleting a specific subject's *documents*
+ needs an index-side delete-by-document path (a follow-up). Tenant-level erasure +
+ short retention windows cover the common case.
+- **Data residency** is a single-deployment *assertion + ingest guard*, not
+ multi-region data routing.
+
+See [ADR-0042](../adr/ADR-0042-compliance-posture.md) for the rationale.
diff --git a/docs/compliance/soc2-control-mapping.md b/docs/compliance/soc2-control-mapping.md
new file mode 100644
index 0000000..9fe691a
--- /dev/null
+++ b/docs/compliance/soc2-control-mapping.md
@@ -0,0 +1,81 @@
+# SOC 2 Type II — control mapping (Step 6.10)
+
+This document maps the AICPA **Trust Service Criteria** (TSC) to the AgentContextOS
+platform features that implement them. It is the human-readable companion to the
+**live posture** at `GET /v1/status/compliance` (and `ragctl compliance report`),
+which reports whether each control is *enabled in your deployment* — so an auditor
+can check the mapping against the running configuration, not just the prose.
+
+> **Scope note.** AgentContextOS is the *software platform*; a SOC 2 Type II report
+> covers your **organisation's operation** of it (policies, personnel, physical
+> security, vendor management, etc.). This map covers the **technical controls the
+> platform provides**; organisational controls are out of scope here.
+
+## How to read this
+
+Each row: a TSC point → the platform control(s) → the config that enables it → the
+posture key reported by `GET /v1/status/compliance`. "Posture key on" means the
+control is active in this deployment.
+
+## Common Criteria (CC) — Security
+
+| TSC | Control area | Platform feature(s) | Enable via | Posture key |
+|-----|--------------|---------------------|-----------|-------------|
+| CC6.1 | Logical access — authentication | SSO (OIDC / SAML) federation; the `Auth` SPI | `cfg.sso` + `tenants[].sso` (Step 6.8) | `sso` |
+| CC6.1 | Logical access — authorization | PolicyEngine PDP; label-based ACL push-down + egress verifier | `cfg.acl` (Steps 1.1c / 6.3 / 6.4) | `acl` |
+| CC6.2 | User provisioning / de-provisioning | SCIM 2.0 directory (`/scim/v2`); deactivate = revoke | `cfg.scim` (Step 6.8) | `scim` |
+| CC6.3 | Least privilege / segregation | Per-tenant ACL labels; logical + physical tenancy | `tenants[].acl_labels` / `dedicated_index` (Steps 6.1–6.4) | `acl` |
+| CC6.6 | Encryption at rest (BYOK) | Envelope encryption (AES-256-GCM DEK + customer KMS KEK); per-tenant sealing | `cfg.kms` (Step 6.7) | `byok` |
+| CC6.7 | Restrict data movement / egress | PII egress policies; data-residency guard | `cfg.pii` / `cfg.compliance.region` (Steps 6.5 / 6.10) | `pii`, `residency` |
+| CC7.1 | Detect config drift | Schema / OpenAPI / proto drift CI gates | always-on CI | — |
+| CC7.2 | Monitoring / anomaly detection | Structured logging; metrics; drift monitors; cost anomaly | `cfg.drift` / `cfg.cost` (Steps 0.7 / 5.5 / 5.6c) | — |
+| CC7.3 | Security event evaluation (audit) | Immutable hash-chain audit log + WORM signed export | `cfg.audit` (Step 6.6) | `audit` |
+| CC8.1 | Change management | CI matrix; SBOM + cosign image signing; signed release bundle | CI / Steps 0.5 / 6.9 | — |
+
+## Availability (A1)
+
+| TSC | Control area | Platform feature(s) | Enable via | Posture key |
+|-----|--------------|---------------------|-----------|-------------|
+| A1.1 | Capacity / rate management | Per-tenant quotas & rate limiting | `cfg.quotas` (Step 4.5) | `quotas` |
+| A1.2 | Fault tolerance | Per-backend circuit breakers; retrieval fallback ladder | `cfg.breakers` / `cfg.retrieval.fallback` (Steps 4.4 / 4.2) | `breakers` |
+| A1.2 | Horizontal scaling / disruption budget | Helm HPA + PodDisruptionBudget | Helm chart (Step 0.9) | — |
+
+## Processing Integrity (PI1)
+
+| TSC | Control area | Platform feature(s) | Enable via | Posture key |
+|-----|--------------|---------------------|-----------|-------------|
+| PI1.1 | Output correctness | Hallucination guard (per-claim NLI faithfulness) | `cfg.guard` (Step 4.3) | `guard` |
+| PI1.2 | Traceable processing | Per-query signed provenance + trace capture | `cfg.provenance` (Step 5.1) | — |
+| PI1.4 | Quality regression control | CI eval gate (golden-set recall / MRR / faithfulness vs baseline) | CI (Step 5.3) | — |
+
+## Confidentiality (C1)
+
+| TSC | Control area | Platform feature(s) | Enable via | Posture key |
+|-----|--------------|---------------------|-----------|-------------|
+| C1.1 | Confidential data identification + protection | Tenant isolation; ACL labels; BYOK; PII detection | Steps 6.1–6.7 | `acl`, `byok` |
+| C1.2 | Disposal of confidential data | Data-retention purge + right-to-erasure | `cfg.compliance.retention` (Step 6.10) | `retention` |
+
+## Privacy (P) — GDPR-aligned
+
+| TSC | Control area | Platform feature(s) | Enable via | Posture key |
+|-----|--------------|---------------------|-----------|-------------|
+| P3 | Collection minimisation | PII detection + redaction at **ingest** (before embedding) | Step 1.7 (always in the ingest pipeline) | `pii` |
+| P4 | Use / retention / disposal | Per-class retention windows; scheduled purge | `cfg.compliance.retention` (Step 6.10) | `retention` |
+| P5 | Access + correction (data subject) | Provenance + audit trail of what produced an answer | Steps 5.1 / 6.6 | `audit` |
+| P5 | Right to erasure | `POST /v1/compliance/erase` (tenant-scoped) | Step 6.10 | `retention` |
+| P6 | Disclosure to third parties | PII egress policies (redact / mask / block) | `cfg.pii` (Step 6.5) | `pii` |
+| P7 | Data residency / cross-border transfer | Per-tenant region pinning enforced at ingest | `cfg.compliance.region` + `tenants[].data_region` (Step 6.10) | `residency` |
+
+See the [GDPR article mapping](gdpr-mapping.md) for an article-by-article view, and
+[`reference/compliance.md`](../reference/compliance.md) for the config + API.
+
+## Verifying the posture
+
+```bash
+ragctl compliance report -f rag.yaml # the controls on/off + region + retention
+curl -s $GATEWAY/v1/status/compliance | jq # the same posture, live from the running gateway
+```
+
+The posture is computed from `cfg`, so an enabled control in the report corresponds
+to a control that is *actually active* — the evidence an auditor checks the mapping
+against.
diff --git a/docs/reference/compliance.md b/docs/reference/compliance.md
new file mode 100644
index 0000000..2424fd0
--- /dev/null
+++ b/docs/reference/compliance.md
@@ -0,0 +1,105 @@
+# Compliance reference (`rag-compliance`, Step 6.10)
+
+Data retention, GDPR right-to-erasure, data-residency, and the live compliance
+posture. Import root: `rag_compliance`.
+
+## Overview
+
+| Concern | Entry point |
+|---------|-------------|
+| Retention purge + GDPR erasure | `RetentionEnforcer` |
+| Live control posture | `compliance_posture(...)` → `CompliancePosture` |
+| Data-residency check | `residency_ok(tenant_region, deployment_region)` |
+
+Core types (`rag_core.types`): `DataClass`, `RetentionPolicy`, `ErasureResult`,
+`CompliancePosture`. Errors: `ComplianceError` (400), `ResidencyViolationError`
+(403). SPI: `purge_before` / `purge_tenant` on `FeedbackStore` + `ProvenanceStore`.
+
+## HTTP surfaces
+
+| Method / path | Purpose |
+|---------------|---------|
+| `GET /v1/status/compliance` | The live posture (controls + region + retention) — the SOC 2 / GDPR mapping backing |
+| `POST /v1/compliance/erase` | GDPR right-to-erasure of the **calling tenant's** data |
+
+```bash
+curl -s $GATEWAY/v1/status/compliance | jq
+# erase: dry-run preview by default; delete needs dry_run=false AND confirm=true
+curl -X POST $GATEWAY/v1/compliance/erase \
+ -H "X-Tenant-Id: acme" -d '{"dry_run": false, "confirm": true}'
+```
+
+The residency guard runs at `POST /v1/ingest/document`: when `cfg.compliance.enabled`
+and a tenant's `data_region` differs from `cfg.compliance.region`, ingest is refused
+with HTTP 403 (`residency_violation`).
+
+## Public API
+
+```python
+from rag_compliance import RetentionEnforcer, compliance_posture, residency_ok
+from rag_core.types import RetentionPolicy
+
+enforcer = RetentionEnforcer(feedback_store=fs, provenance_store=ps)
+preview = await enforcer.purge(ctx, RetentionPolicy(feedback_days=30), dry_run=True) # count
+applied = await enforcer.purge(ctx, RetentionPolicy(feedback_days=30)) # delete
+erased = await enforcer.erase_tenant(ctx) # GDPR erasure
+
+posture = compliance_posture(region="eu-west-1", controls={"audit": True}, retention=...)
+assert residency_ok("eu-west-1", "eu-west-1")
+```
+
+- `RetentionEnforcer.purge(ctx, policy, *, dry_run=False)` — purge each data class
+ past its window; returns `ErasureResult` (per-class counts). Emits
+ `compliance.data_purged` when ≥1 removed.
+- `RetentionEnforcer.erase_tenant(ctx, *, dry_run=False)` — erase all of the
+ tenant's data; emits `compliance.tenant_erased`.
+- `dry_run` counts without deleting (the SPI `purge_*` methods honour it).
+
+## Configuration
+
+```yaml
+compliance:
+ enabled: false # off by default (residency can refuse ingest; erasure deletes)
+ region: "" # this deployment's data region (GDPR residency); empty = none
+ retention: # per-class windows in days; omit/null = retain indefinitely
+ chunks_days: null
+ feedback_days: 30
+ provenance_days: 90
+ traces_days: 30
+ audit_days: 365 # advisory — audit retention is the WORM export, not in-place purge
+tenants:
+ - id: acme
+ data_region: eu-west-1 # must match compliance.region or ingest is refused
+ retention_days: 60 # per-tenant cap across all classes (tightens the global windows)
+```
+
+## `ragctl`
+
+```bash
+ragctl compliance report -f rag.yaml # the posture: controls on/off, region, retention windows
+ragctl compliance demo --tenant acme # in-process retention purge + erasure demo
+```
+
+## Events (PII-free)
+
+| Event | When |
+|-------|------|
+| `compliance.data_purged` | A retention purge removed ≥1 record |
+| `compliance.tenant_erased` | A GDPR right-to-erasure run |
+| `compliance.residency_violation` | An ingest refused on a region mismatch |
+
+All carry only operation / counts / region labels — never subject data.
+
+## Extension points
+
+- **Durable retention** — implement `purge_before` / `purge_tenant` on a real
+ `FeedbackStore` / `ProvenanceStore` (delete-by-age / by-tenant in the backend);
+ the contract suite is `tests/contract/test_retention_purge.py`.
+- **More data classes** — wire additional stores into the `RetentionEnforcer`
+ (chunk/document erasure needs an index-side delete-by-document path — deferred).
+- **The posture** — `compliance_posture(...)` takes the control booleans, so a
+ custom deployment can report extra controls.
+
+See [architecture/compliance.md](../architecture/compliance.md),
+[compliance/soc2-control-mapping.md](../compliance/soc2-control-mapping.md), and
+[ADR-0042](../adr/ADR-0042-compliance-posture.md).
diff --git a/packages/compliance/README.md b/packages/compliance/README.md
new file mode 100644
index 0000000..0db7010
--- /dev/null
+++ b/packages/compliance/README.md
@@ -0,0 +1,20 @@
+# rag-compliance
+
+Compliance posture for AgentContextOS (Step 6.10): **data retention**, **GDPR
+right-to-erasure**, **data-residency**, and the live **control posture** that backs
+the SOC 2 / GDPR control-mapping docs.
+
+- `RetentionEnforcer` — drives the tenant-scoped `purge_*` SPI methods to apply
+ per-data-class retention windows (`purge`) and erase a tenant entirely
+ (`erase_tenant`, GDPR right-to-erasure). Both support a `dry_run` preview and
+ emit PII-free `compliance.*` events.
+- `compliance_posture(...)` → `CompliancePosture` — which governance controls are
+ on + the deployment region + retention windows.
+- `residency_ok(tenant_region, deployment_region)` — the data-residency check
+ applied at the ingest boundary.
+
+Depends only on `rag-core` + `rag-observability`. See
+[`docs/reference/compliance.md`](../../docs/reference/compliance.md),
+[`docs/architecture/compliance.md`](../../docs/architecture/compliance.md),
+[`docs/compliance/soc2-control-mapping.md`](../../docs/compliance/soc2-control-mapping.md),
+and [ADR-0042](../../docs/adr/ADR-0042-compliance-posture.md).
diff --git a/packages/compliance/pyproject.toml b/packages/compliance/pyproject.toml
new file mode 100644
index 0000000..0bd707c
--- /dev/null
+++ b/packages/compliance/pyproject.toml
@@ -0,0 +1,35 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "rag-compliance"
+version = "0.1.0"
+description = "AgentContextOS — compliance posture: data retention, GDPR erasure/residency, SOC 2 control mapping"
+readme = "README.md"
+requires-python = ">=3.12"
+# rag-compliance depends only on rag-core (the RetentionPolicy / CompliancePosture
+# / ErasureResult types + the store SPIs whose purge methods it drives) and
+# rag-observability (structured logger + the compliance.* events). No heavy deps:
+# retention runs over whatever stores are wired (in-memory by default).
+dependencies = [
+ "rag-core",
+ "rag-observability",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=9.0",
+ "pytest-asyncio>=1.3",
+ "mypy>=2.1",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/rag_compliance"]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.uv.sources]
+rag-core = { workspace = true }
+rag-observability = { workspace = true }
diff --git a/packages/compliance/src/rag_compliance/__init__.py b/packages/compliance/src/rag_compliance/__init__.py
new file mode 100644
index 0000000..a822b39
--- /dev/null
+++ b/packages/compliance/src/rag_compliance/__init__.py
@@ -0,0 +1,18 @@
+"""rag-compliance — compliance posture: retention, GDPR erasure/residency (Step 6.10).
+
+Two pieces:
+
+* ``RetentionEnforcer`` — drives the tenant-scoped ``purge_*`` SPI methods to apply
+ per-class data-retention windows (:meth:`purge`) and the GDPR right-to-erasure
+ (:meth:`erase_tenant`); both support a ``dry_run`` preview and emit PII-free
+ ``compliance.*`` events.
+* ``compliance_posture`` / ``residency_ok`` — the live control posture (the
+ machine-readable backing for the SOC 2 / GDPR mapping) + the data-residency check.
+"""
+
+from __future__ import annotations
+
+from rag_compliance.posture import compliance_posture, residency_ok
+from rag_compliance.retention import RetentionEnforcer
+
+__all__ = ["RetentionEnforcer", "compliance_posture", "residency_ok"]
diff --git a/packages/compliance/src/rag_compliance/posture.py b/packages/compliance/src/rag_compliance/posture.py
new file mode 100644
index 0000000..1737a08
--- /dev/null
+++ b/packages/compliance/src/rag_compliance/posture.py
@@ -0,0 +1,56 @@
+"""Compliance posture + data-residency helpers (Step 6.10).
+
+``compliance_posture`` assembles the live :class:`~rag_core.types.CompliancePosture`
+from the control booleans the gateway derives from ``cfg`` — the machine-readable
+backing for the SOC 2 / GDPR control-mapping docs. ``residency_ok`` is the pure
+GDPR data-residency check applied at the ingest boundary.
+
+Kept config-free (control booleans are passed in) so the package stays in the
+``compliance → core/observability`` dependency lane.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+
+from rag_core.types import CompliancePosture, RetentionPolicy
+
+__all__ = ["compliance_posture", "residency_ok"]
+
+
+def compliance_posture(
+ *,
+ region: str = "",
+ controls: Mapping[str, bool] | None = None,
+ retention: RetentionPolicy | None = None,
+) -> CompliancePosture:
+ """Assemble the :class:`CompliancePosture` from the deployment's control flags.
+
+ ``controls`` maps a governance-control name (``audit`` / ``acl`` / ``pii`` /
+ ``byok`` / ``sso`` / ``scim`` / ``quotas`` / ``retention`` / ``residency``) to
+ whether it is enabled in this deployment; ``enabled_controls`` is the sorted
+ subset that are on.
+ """
+ resolved = dict(controls or {})
+ enabled = tuple(sorted(name for name, on in resolved.items() if on))
+ return CompliancePosture(
+ region=region,
+ controls=resolved,
+ enabled_controls=enabled,
+ retention=retention or RetentionPolicy(),
+ )
+
+
+def residency_ok(tenant_region: str | None, deployment_region: str) -> bool:
+ """GDPR data-residency check: may this deployment serve *tenant_region*?
+
+ * A tenant with **no** required region (``None`` / empty) always passes.
+ * A deployment that declares **no** region (empty ``deployment_region``)
+ enforces nothing — every tenant passes (residency is opt-in).
+ * Otherwise the deployment's region must equal the tenant's required region.
+ """
+ if not tenant_region:
+ return True
+ if not deployment_region:
+ return True
+ return tenant_region == deployment_region
diff --git a/packages/compliance/src/rag_compliance/py.typed b/packages/compliance/src/rag_compliance/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/packages/compliance/src/rag_compliance/retention.py b/packages/compliance/src/rag_compliance/retention.py
new file mode 100644
index 0000000..910066d
--- /dev/null
+++ b/packages/compliance/src/rag_compliance/retention.py
@@ -0,0 +1,112 @@
+"""Data-retention enforcement + GDPR right-to-erasure (Step 6.10).
+
+The ``RetentionEnforcer`` drives the tenant-scoped ``purge_*`` methods on the
+wired stores: :meth:`purge` removes records past their per-class retention window
+(driven by a :class:`~rag_core.types.RetentionPolicy`), and :meth:`erase_tenant`
+removes *all* of a tenant's records (the GDPR right-to-erasure primitive). Both
+support ``dry_run`` — the stores count matches without deleting — so an operator
+can preview a purge. Every run emits a PII-free ``compliance.*`` event.
+
+The enforcer holds no config: it takes the resolved :class:`RetentionPolicy` and a
+``RequestContext`` (the tenant scope) per call, so it stays in the
+``compliance → core/observability`` dependency lane (no ``rag-config``).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from rag_core.types import DataClass, ErasureResult, RequestContext, RetentionPolicy
+from rag_observability.events import (
+ EVT_COMPLIANCE_DATA_PURGED,
+ EVT_COMPLIANCE_TENANT_ERASED,
+ ComplianceEvent,
+)
+from rag_observability.logging import get_logger
+
+__all__ = ["RetentionEnforcer"]
+
+_log = get_logger(__name__)
+
+
+class RetentionEnforcer:
+ """Purges expired / erased tenant data across the wired stores (Step 6.10).
+
+ Wire the stores that support purge (feedback, provenance today); a ``None``
+ store is simply skipped. ``clock`` is injectable for deterministic tests.
+ """
+
+ def __init__(
+ self,
+ *,
+ feedback_store: Any = None,
+ provenance_store: Any = None,
+ clock: Callable[[], datetime] | None = None,
+ ) -> None:
+ self._feedback = feedback_store
+ self._provenance = provenance_store
+ self._clock = clock or (lambda: datetime.now(tz=UTC))
+
+ def _stores(self) -> dict[DataClass, Any]:
+ out: dict[DataClass, Any] = {}
+ if self._feedback is not None:
+ out[DataClass.feedback] = self._feedback
+ if self._provenance is not None:
+ out[DataClass.provenance] = self._provenance
+ return out
+
+ async def purge(
+ self, ctx: RequestContext, policy: RetentionPolicy, *, dry_run: bool = False
+ ) -> ErasureResult:
+ """Purge ``ctx.tenant_id``'s records past their per-class retention window."""
+ now = self._clock()
+ purged: dict[str, int] = {}
+ for data_class, store in self._stores().items():
+ days = policy.days_for(data_class)
+ if days is None:
+ continue # retain indefinitely
+ cutoff = now - timedelta(days=days)
+ n = await store.purge_before(ctx, cutoff, dry_run=dry_run)
+ if n:
+ purged[data_class.value] = n
+ result = ErasureResult(
+ tenant_id=ctx.tenant_id,
+ scope="retention",
+ purged=purged,
+ total=sum(purged.values()),
+ dry_run=dry_run,
+ )
+ if result.total:
+ self._emit(EVT_COMPLIANCE_DATA_PURGED, "purge", result)
+ return result
+
+ async def erase_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> ErasureResult:
+ """Erase **all** of ``ctx.tenant_id``'s records (GDPR right-to-erasure)."""
+ purged: dict[str, int] = {}
+ for data_class, store in self._stores().items():
+ n = await store.purge_tenant(ctx, dry_run=dry_run)
+ if n:
+ purged[data_class.value] = n
+ result = ErasureResult(
+ tenant_id=ctx.tenant_id,
+ scope="erasure",
+ purged=purged,
+ total=sum(purged.values()),
+ dry_run=dry_run,
+ )
+ self._emit(EVT_COMPLIANCE_TENANT_ERASED, "erase", result)
+ return result
+
+ def _emit(self, event_name: str, operation: str, result: ErasureResult) -> None:
+ _log.info(
+ ComplianceEvent(
+ event_name=event_name,
+ tenant_id=str(result.tenant_id),
+ operation=operation,
+ purged_total=result.total,
+ data_classes=",".join(sorted(result.purged)),
+ dry_run=result.dry_run,
+ ).model_dump_json()
+ )
diff --git a/packages/compliance/tests/test_retention.py b/packages/compliance/tests/test_retention.py
new file mode 100644
index 0000000..4e90cad
--- /dev/null
+++ b/packages/compliance/tests/test_retention.py
@@ -0,0 +1,129 @@
+"""Unit tests for rag-compliance — RetentionEnforcer + posture + residency (Step 6.10)."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+from rag_compliance import RetentionEnforcer, compliance_posture, residency_ok
+from rag_core.spi.noop import NoopFeedbackStore, NoopProvenanceStore
+from rag_core.types import (
+ FeedbackRecord,
+ FeedbackSignal,
+ Principal,
+ PrincipalId,
+ PrincipalKind,
+ ProvenanceRecord,
+ RequestContext,
+ RequestId,
+ RetentionPolicy,
+ SignedProvenanceRecord,
+ TenantId,
+)
+
+NOW = datetime(2026, 6, 8, tzinfo=UTC)
+
+
+def _ctx(tenant: str = "acme") -> RequestContext:
+ tid = TenantId(tenant)
+ return RequestContext(
+ tenant_id=tid,
+ principal=Principal(
+ id=PrincipalId("p"), kind=PrincipalKind.service, display_name="p", tenant_id=tid
+ ),
+ )
+
+
+def _feedback(ctx: RequestContext, fid: str, *, age_days: int) -> FeedbackRecord:
+ return FeedbackRecord(
+ feedback_id=fid,
+ request_id=RequestId(fid),
+ tenant_id=ctx.tenant_id,
+ signal=FeedbackSignal.thumb_up,
+ created_at=NOW - timedelta(days=age_days),
+ )
+
+
+def _prov(ctx: RequestContext, rid: str, *, age_days: int) -> SignedProvenanceRecord:
+ return SignedProvenanceRecord(
+ record=ProvenanceRecord(
+ request_id=RequestId(rid),
+ trace_id=rid,
+ tenant_id=ctx.tenant_id,
+ created_at=NOW - timedelta(days=age_days),
+ )
+ )
+
+
+async def test_purge_applies_per_class_windows() -> None:
+ fs, ps = NoopFeedbackStore(), NoopProvenanceStore()
+ ctx = _ctx()
+ await fs.put(ctx, _feedback(ctx, "f-old", age_days=100))
+ await fs.put(ctx, _feedback(ctx, "f-new", age_days=1))
+ await ps.put(ctx, _prov(ctx, "p-old", age_days=200))
+ enf = RetentionEnforcer(feedback_store=fs, provenance_store=ps, clock=lambda: NOW)
+
+ result = await enf.purge(ctx, RetentionPolicy(feedback_days=30, provenance_days=90))
+ assert result.scope == "retention"
+ assert result.purged == {"feedback": 1, "provenance": 1}
+ assert result.total == 2
+ assert result.dry_run is False
+
+
+async def test_purge_dry_run_counts_without_deleting() -> None:
+ fs = NoopFeedbackStore()
+ ctx = _ctx()
+ await fs.put(ctx, _feedback(ctx, "old", age_days=100))
+ enf = RetentionEnforcer(feedback_store=fs, clock=lambda: NOW)
+
+ preview = await enf.purge(ctx, RetentionPolicy(feedback_days=30), dry_run=True)
+ assert preview.total == 1 and preview.dry_run is True
+ assert len(await fs.list(ctx)) == 1 # nothing deleted
+
+
+async def test_purge_skips_classes_with_no_window() -> None:
+ fs = NoopFeedbackStore()
+ ctx = _ctx()
+ await fs.put(ctx, _feedback(ctx, "old", age_days=999))
+ enf = RetentionEnforcer(feedback_store=fs, clock=lambda: NOW)
+ # No feedback_days configured → retain indefinitely → nothing purged.
+ result = await enf.purge(ctx, RetentionPolicy(provenance_days=30))
+ assert result.total == 0
+ assert len(await fs.list(ctx)) == 1
+
+
+async def test_erase_tenant_removes_everything() -> None:
+ fs, ps = NoopFeedbackStore(), NoopProvenanceStore()
+ ctx = _ctx()
+ await fs.put(ctx, _feedback(ctx, "a", age_days=1))
+ await ps.put(ctx, _prov(ctx, "b", age_days=1))
+ enf = RetentionEnforcer(feedback_store=fs, provenance_store=ps)
+
+ result = await enf.erase_tenant(ctx)
+ assert result.scope == "erasure"
+ assert result.total == 2
+ assert await fs.list(ctx) == []
+
+
+async def test_enforcer_with_no_stores_is_noop() -> None:
+ enf = RetentionEnforcer()
+ result = await enf.purge(_ctx(), RetentionPolicy(feedback_days=1))
+ assert result.total == 0
+
+
+def test_compliance_posture() -> None:
+ posture = compliance_posture(
+ region="eu-west-1",
+ controls={"audit": True, "pii": False, "sso": True},
+ retention=RetentionPolicy(feedback_days=30),
+ )
+ assert posture.region == "eu-west-1"
+ assert posture.enabled_controls == ("audit", "sso")
+ assert posture.controls["pii"] is False
+ assert posture.retention.feedback_days == 30
+
+
+def test_residency_ok() -> None:
+ assert residency_ok("eu-west-1", "eu-west-1") is True
+ assert residency_ok("eu-west-1", "us-east-1") is False
+ assert residency_ok(None, "us-east-1") is True # tenant has no requirement
+ assert residency_ok("eu-west-1", "") is True # deployment declares no region
diff --git a/packages/config/src/rag_config/schema.py b/packages/config/src/rag_config/schema.py
index 2847f4b..d319534 100644
--- a/packages/config/src/rag_config/schema.py
+++ b/packages/config/src/rag_config/schema.py
@@ -350,6 +350,15 @@ class TenantConfig(_StrictBase):
# block, so a bearer token / SAML assertion for the tenant is verified against
# its own IdP. ``None`` → the tenant has no federated IdP.
sso: TenantSsoConfig | None = None
+ # Compliance (Step 6.10): GDPR data-residency — the region this tenant's data
+ # must stay in. When ``compliance.enabled`` and this differs from
+ # ``compliance.region``, ingest for the tenant is refused (residency violation).
+ # ``None`` → no per-tenant residency requirement.
+ data_region: str | None = None
+ # Per-tenant retention cap (days) applied across every data class — overrides
+ # (tightens) the global ``compliance.retention`` for this tenant. ``None`` →
+ # inherit the global per-class windows.
+ retention_days: Annotated[int, Field(ge=1)] | None = None
# ---------------------------------------------------------------------------
@@ -818,6 +827,45 @@ class ScimConfig(_StrictBase):
tokens: dict[str, str] = Field(default_factory=dict)
+class RetentionConfig(_StrictBase):
+ """Per-data-class retention windows in days (Step 6.10).
+
+ Each field is the number of days to retain that data class; ``None`` (default)
+ means **retain indefinitely**. Resolved into a ``rag_core.types.RetentionPolicy``
+ and enforced by the ``RetentionEnforcer`` (purges expired feedback / provenance
+ / trace records). ``audit_days`` is advisory only — the tamper-evident audit
+ chain (Step 6.6) is retained via its WORM **export**, never purged in place.
+ A per-tenant ``tenants[].retention_days`` caps every class for that tenant.
+ """
+
+ chunks_days: Annotated[int, Field(ge=1)] | None = None
+ feedback_days: Annotated[int, Field(ge=1)] | None = None
+ provenance_days: Annotated[int, Field(ge=1)] | None = None
+ traces_days: Annotated[int, Field(ge=1)] | None = None
+ audit_days: Annotated[int, Field(ge=1)] | None = None
+
+
+class ComplianceConfig(_StrictBase):
+ """Compliance posture — data retention + GDPR residency (Step 6.10).
+
+ When ``enabled`` the gateway exposes ``GET /v1/status/compliance`` (the live
+ control posture backing the SOC 2 / GDPR mapping) and ``POST /v1/compliance/
+ erase`` (tenant-scoped right-to-erasure), runs the ``RetentionEnforcer`` over
+ the wired stores, and enforces data-residency: a tenant whose
+ ``tenants[].data_region`` differs from ``region`` is refused at ingest
+ (``ResidencyViolationError`` → HTTP 403).
+
+ **Disabled by default** — residency enforcement can refuse ingest and erasure
+ deletes data, so it is opt-in. ``region`` is this deployment's declared data
+ region (e.g. ``eu-west-1``); leave it empty to declare no region (residency
+ checks then pass for every tenant).
+ """
+
+ enabled: bool = False
+ region: str = ""
+ retention: RetentionConfig = Field(default_factory=RetentionConfig)
+
+
# ---------------------------------------------------------------------------
# Corpus registry section (Step 3.5)
# ---------------------------------------------------------------------------
@@ -1099,6 +1147,7 @@ class RagConfig(_StrictBase):
kms: KmsConfig = Field(default_factory=KmsConfig)
sso: SsoConfig = Field(default_factory=SsoConfig)
scim: ScimConfig = Field(default_factory=ScimConfig)
+ compliance: ComplianceConfig = Field(default_factory=ComplianceConfig)
webhooks: WebhooksConfig = Field(default_factory=WebhooksConfig)
provenance: ProvenanceConfig = Field(default_factory=ProvenanceConfig)
feedback: FeedbackConfig = Field(default_factory=FeedbackConfig)
diff --git a/packages/config/src/rag_config/tenancy.py b/packages/config/src/rag_config/tenancy.py
index e65e02e..8413203 100644
--- a/packages/config/src/rag_config/tenancy.py
+++ b/packages/config/src/rag_config/tenancy.py
@@ -39,6 +39,7 @@ def settings_for_tenant(tenant: TenantConfig) -> TenantSettings:
pii_policy=_pii_policy_from(tenant.pii_policy),
acl_labels=frozenset(tenant.acl_labels),
physical_index=_physical_index_for(tenant),
+ data_region=tenant.data_region,
)
diff --git a/packages/core/src/rag_core/errors.py b/packages/core/src/rag_core/errors.py
index b98b4ef..c8ae570 100644
--- a/packages/core/src/rag_core/errors.py
+++ b/packages/core/src/rag_core/errors.py
@@ -369,3 +369,23 @@ class ScimConflictError(ScimError):
"""
code = "scim_conflict"
+
+
+# ---------------------------------------------------------------------------
+# Compliance posture (Step 6.10)
+# ---------------------------------------------------------------------------
+class ComplianceError(RagError):
+ """A compliance-posture operation could not be satisfied (Step 6.10)."""
+
+ code = "compliance_error"
+
+
+class ResidencyViolationError(ComplianceError):
+ """A tenant's data would be placed outside its declared region (Step 6.10).
+
+ Raised at the ingest boundary when a tenant's ``data_region`` does not match
+ the deployment's ``cfg.compliance.region`` — GDPR data-residency enforcement.
+ Maps to HTTP 403 at the gateway (the request is refused, not retried).
+ """
+
+ code = "residency_violation"
diff --git a/packages/core/src/rag_core/events.py b/packages/core/src/rag_core/events.py
index af4c7f2..4133fa9 100644
--- a/packages/core/src/rag_core/events.py
+++ b/packages/core/src/rag_core/events.py
@@ -11,6 +11,15 @@
from rag_observability.events import EVT_CACHE_HIT as EVT_CACHE_HIT
from rag_observability.events import EVT_CACHE_INVALIDATED as EVT_CACHE_INVALIDATED
from rag_observability.events import EVT_CACHE_MISS as EVT_CACHE_MISS
+from rag_observability.events import (
+ EVT_COMPLIANCE_DATA_PURGED as EVT_COMPLIANCE_DATA_PURGED,
+)
+from rag_observability.events import (
+ EVT_COMPLIANCE_RESIDENCY_VIOLATION as EVT_COMPLIANCE_RESIDENCY_VIOLATION,
+)
+from rag_observability.events import (
+ EVT_COMPLIANCE_TENANT_ERASED as EVT_COMPLIANCE_TENANT_ERASED,
+)
from rag_observability.events import EVT_DRIFT_DETECTED as EVT_DRIFT_DETECTED
from rag_observability.events import EVT_FALLBACK_ENGAGED as EVT_FALLBACK_ENGAGED
from rag_observability.events import (
@@ -43,6 +52,7 @@
from rag_observability.events import EVT_SSO_AUTHENTICATED as EVT_SSO_AUTHENTICATED
from rag_observability.events import BreakerEvent as BreakerEvent
from rag_observability.events import CacheEvent as CacheEvent
+from rag_observability.events import ComplianceEvent as ComplianceEvent
from rag_observability.events import DriftEvent as DriftEvent
from rag_observability.events import FeedbackEvent as FeedbackEvent
from rag_observability.events import GuardEvent as GuardEvent
@@ -72,6 +82,7 @@
"DriftEvent",
"SsoEvent",
"ScimEvent",
+ "ComplianceEvent",
"EVT_INGEST_STARTED",
"EVT_INGEST_COMPLETED",
"EVT_INGEST_FAILED",
@@ -100,5 +111,8 @@
"EVT_SCIM_USER_PROVISIONED",
"EVT_SCIM_USER_DEPROVISIONED",
"EVT_SCIM_GROUP_CHANGED",
+ "EVT_COMPLIANCE_DATA_PURGED",
+ "EVT_COMPLIANCE_TENANT_ERASED",
+ "EVT_COMPLIANCE_RESIDENCY_VIOLATION",
"check_pii",
]
diff --git a/packages/core/src/rag_core/gateway_types.py b/packages/core/src/rag_core/gateway_types.py
index 09986ce..efaa1ca 100644
--- a/packages/core/src/rag_core/gateway_types.py
+++ b/packages/core/src/rag_core/gateway_types.py
@@ -551,6 +551,22 @@ class SsoStatusResponse(BaseModel):
tenant: SsoTenantStatus
+class ComplianceEraseRequest(BaseModel):
+ """``POST /v1/compliance/erase`` body — GDPR right-to-erasure (Step 6.10).
+
+ Erases the **calling tenant's** data across the retention-enforcer's wired
+ stores (tenant scope comes from the authenticated principal, never the body).
+ ``dry_run`` defaults to ``True`` (a safe preview that *counts* without
+ deleting); set it ``false`` **and** ``confirm`` ``true`` to actually delete —
+ both are required so an erasure is never a one-flag accident.
+ """
+
+ model_config = {"frozen": True}
+
+ dry_run: bool = True
+ confirm: bool = False
+
+
class GatewayError(BaseModel):
"""JSON error body returned for 4xx / 5xx responses.
@@ -577,6 +593,7 @@ class GatewayError(BaseModel):
"Answer",
"AuditListResponse",
"AuditVerifyResponse",
+ "ComplianceEraseRequest",
"Corpus",
"CorpusList",
"ExperimentAssignment",
diff --git a/packages/core/src/rag_core/gen_schemas.py b/packages/core/src/rag_core/gen_schemas.py
index c49c00e..4065d08 100644
--- a/packages/core/src/rag_core/gen_schemas.py
+++ b/packages/core/src/rag_core/gen_schemas.py
@@ -26,6 +26,7 @@
from rag_core.filter import And, AnyIn, Eq, Not, Or, TrueExpr
from rag_core.gateway_types import (
Answer,
+ ComplianceEraseRequest,
Corpus,
CorpusList,
ExperimentAssignment,
@@ -68,6 +69,7 @@
ClaimVerdict,
Community,
CommunitySummary,
+ CompliancePosture,
ConflictAnnotation,
ConnectorState,
CorpusRoutingDecision,
@@ -77,6 +79,7 @@
DriftReport,
DriftSnapshot,
Embedding,
+ ErasureResult,
FallbackResult,
FederatedIdentity,
FeedbackRecord,
@@ -104,6 +107,7 @@
QuotaSnapshot,
QuotaVerdict,
RequestContext,
+ RetentionPolicy,
RetrievalResult,
RoutingDecision,
ScimGroup,
@@ -190,6 +194,11 @@
FederatedIdentity,
ScimUser,
ScimGroup,
+ # Compliance posture — retention / residency / control mapping (Step 6.10).
+ RetentionPolicy,
+ ErasureResult,
+ CompliancePosture,
+ ComplianceEraseRequest,
CorpusScore,
CorpusRoutingDecision,
AuditEvent,
diff --git a/packages/core/src/rag_core/spi/feedback_store.py b/packages/core/src/rag_core/spi/feedback_store.py
index 92a7d39..87a5c1e 100644
--- a/packages/core/src/rag_core/spi/feedback_store.py
+++ b/packages/core/src/rag_core/spi/feedback_store.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import abc
+from datetime import datetime
from rag_core.spi._base import HealthCheckMixin
from rag_core.types import FeedbackRecord, RequestContext, RequestId
@@ -35,3 +36,23 @@ async def list(
Filtered to *request_id* when given; capped at *limit*.
"""
+
+ async def purge_before(
+ self, ctx: RequestContext, cutoff: datetime, *, dry_run: bool = False
+ ) -> int:
+ """Delete ``ctx.tenant_id``'s records older than *cutoff*; return the count.
+
+ With *dry_run* the matching records are **counted but not deleted** (a
+ retention preview). The Step 6.10 data-retention primitive. Non-abstract
+ default (no-op, returns 0) so existing backends are unaffected; the
+ in-memory store and retention-aware backends override it.
+ """
+ return 0
+
+ async def purge_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> int:
+ """Delete **all** of ``ctx.tenant_id``'s records; return the count.
+
+ With *dry_run* the records are counted but not deleted. The Step 6.10 GDPR
+ right-to-erasure primitive. Non-abstract default (no-op).
+ """
+ return 0
diff --git a/packages/core/src/rag_core/spi/noop/feedback_store.py b/packages/core/src/rag_core/spi/noop/feedback_store.py
index 09d7aa5..c4c9f0b 100644
--- a/packages/core/src/rag_core/spi/noop/feedback_store.py
+++ b/packages/core/src/rag_core/spi/noop/feedback_store.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections import OrderedDict
+from datetime import datetime
from rag_core.spi.feedback_store import FeedbackStore
from rag_core.types import FeedbackRecord, RequestContext, RequestId
@@ -52,5 +53,27 @@ async def list(
break
return out
+ async def purge_before(
+ self, ctx: RequestContext, cutoff: datetime, *, dry_run: bool = False
+ ) -> int:
+ tenant = str(ctx.tenant_id)
+ doomed = [
+ key
+ for key, rec in self._records.items()
+ if key[0] == tenant and rec.created_at < cutoff
+ ]
+ if not dry_run:
+ for key in doomed:
+ del self._records[key]
+ return len(doomed)
+
+ async def purge_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> int:
+ tenant = str(ctx.tenant_id)
+ doomed = [key for key in self._records if key[0] == tenant]
+ if not dry_run:
+ for key in doomed:
+ del self._records[key]
+ return len(doomed)
+
async def health(self) -> bool:
return True
diff --git a/packages/core/src/rag_core/spi/noop/provenance_store.py b/packages/core/src/rag_core/spi/noop/provenance_store.py
index 78fdf5e..7db441f 100644
--- a/packages/core/src/rag_core/spi/noop/provenance_store.py
+++ b/packages/core/src/rag_core/spi/noop/provenance_store.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections import OrderedDict
+from datetime import datetime
from rag_core.spi.provenance_store import ProvenanceStore
from rag_core.types import RequestContext, RequestId, SignedProvenanceRecord
@@ -36,5 +37,27 @@ async def get(
) -> SignedProvenanceRecord | None:
return self._records.get((str(ctx.tenant_id), str(request_id)))
+ async def purge_before(
+ self, ctx: RequestContext, cutoff: datetime, *, dry_run: bool = False
+ ) -> int:
+ tenant = str(ctx.tenant_id)
+ doomed = [
+ key
+ for key, rec in self._records.items()
+ if key[0] == tenant and rec.record.created_at < cutoff
+ ]
+ if not dry_run:
+ for key in doomed:
+ del self._records[key]
+ return len(doomed)
+
+ async def purge_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> int:
+ tenant = str(ctx.tenant_id)
+ doomed = [key for key in self._records if key[0] == tenant]
+ if not dry_run:
+ for key in doomed:
+ del self._records[key]
+ return len(doomed)
+
async def health(self) -> bool:
return True
diff --git a/packages/core/src/rag_core/spi/provenance_store.py b/packages/core/src/rag_core/spi/provenance_store.py
index 07a649b..d92a23e 100644
--- a/packages/core/src/rag_core/spi/provenance_store.py
+++ b/packages/core/src/rag_core/spi/provenance_store.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import abc
+from datetime import datetime
from rag_core.spi._base import HealthCheckMixin
from rag_core.types import RequestContext, RequestId, SignedProvenanceRecord
@@ -27,3 +28,23 @@ async def get(
self, ctx: RequestContext, request_id: RequestId
) -> SignedProvenanceRecord | None:
"""Return the record for *request_id* within ``ctx.tenant_id``, else ``None``."""
+
+ async def purge_before(
+ self, ctx: RequestContext, cutoff: datetime, *, dry_run: bool = False
+ ) -> int:
+ """Delete ``ctx.tenant_id``'s records older than *cutoff*; return the count.
+
+ With *dry_run* the matching records are **counted but not deleted** (a
+ retention preview). The Step 6.10 data-retention primitive. Non-abstract
+ default (no-op, returns 0) so existing backends are unaffected; the
+ in-memory store and retention-aware backends override it.
+ """
+ return 0
+
+ async def purge_tenant(self, ctx: RequestContext, *, dry_run: bool = False) -> int:
+ """Delete **all** of ``ctx.tenant_id``'s records; return the count.
+
+ With *dry_run* the records are counted but not deleted. The Step 6.10 GDPR
+ right-to-erasure primitive. Non-abstract default (no-op).
+ """
+ return 0
diff --git a/packages/core/src/rag_core/types.py b/packages/core/src/rag_core/types.py
index 9b41368..81a1fcd 100644
--- a/packages/core/src/rag_core/types.py
+++ b/packages/core/src/rag_core/types.py
@@ -444,6 +444,10 @@ class TenantSettings(BaseModel):
# backend namespaces its base under (``-``) when the
# tenant has a *dedicated* index. ``None`` → the shared base index.
physical_index: str | None = None
+ # GDPR data-residency (Step 6.10): the region this tenant's data must stay in.
+ # Checked at the ingest boundary against ``cfg.compliance.region``; ``None`` →
+ # no per-tenant residency requirement.
+ data_region: str | None = None
# ---------------------------------------------------------------------------
@@ -2188,3 +2192,86 @@ class ScimGroup(BaseModel):
display_name: str = Field(alias="displayName")
members: tuple[ScimMember, ...] = Field(default_factory=tuple)
meta: ScimResourceMeta | None = None
+
+
+# ---------------------------------------------------------------------------
+# Compliance posture — retention / residency / control mapping (Step 6.10)
+# ---------------------------------------------------------------------------
+class DataClass(StrEnum):
+ """A category of tenant data a retention policy applies to (Step 6.10)."""
+
+ chunks = "chunks" # indexed document content (the system of record)
+ audit = "audit" # tamper-evident audit events
+ feedback = "feedback" # online feedback / implicit signals
+ provenance = "provenance" # per-query signed provenance records
+ traces = "traces" # captured per-query span trees
+
+
+class RetentionPolicy(BaseModel):
+ """Per-data-class retention windows in days (Step 6.10).
+
+ ``None`` on a class means **retain indefinitely** (no purge for that class).
+ The runtime counterpart of ``cfg.compliance.retention`` — resolved per tenant
+ at the boundary and consumed by the ``RetentionEnforcer``. ``audit_days`` is
+ advisory: the tamper-evident hash chain (Step 6.6) is not purged in place —
+ audit retention is the WORM **export** lifecycle — so the enforcer never drops
+ audit events, it only reports the configured window.
+ """
+
+ model_config = {"frozen": True}
+
+ chunks_days: int | None = None
+ feedback_days: int | None = None
+ provenance_days: int | None = None
+ traces_days: int | None = None
+ audit_days: int | None = None
+
+ def days_for(self, data_class: DataClass) -> int | None:
+ """Retention window (days) for *data_class*, or ``None`` (keep forever)."""
+ return {
+ DataClass.chunks: self.chunks_days,
+ DataClass.feedback: self.feedback_days,
+ DataClass.provenance: self.provenance_days,
+ DataClass.traces: self.traces_days,
+ DataClass.audit: self.audit_days,
+ }[data_class]
+
+
+class ErasureResult(BaseModel):
+ """Outcome of a retention purge or a GDPR right-to-erasure run (Step 6.10).
+
+ ``purged`` maps each :class:`DataClass` (by value) to the number of records
+ removed; ``total`` is their sum. ``dry_run`` is ``True`` when the run only
+ *counted* what would be purged without deleting (the default for a preview).
+ """
+
+ model_config = {"frozen": True}
+
+ tenant_id: TenantId
+ scope: str = "retention" # "retention" (age-based) | "erasure" (whole-tenant)
+ purged: dict[str, int] = Field(default_factory=dict)
+ total: int = 0
+ dry_run: bool = False
+ generated_at: datetime = Field(default_factory=_utcnow)
+
+
+class CompliancePosture(BaseModel):
+ """The platform's live compliance posture (Step 6.10).
+
+ A point-in-time view of which governance controls are enabled, the deployment's
+ data region (GDPR residency), and the retention windows — computed from ``cfg``
+ by ``rag_compliance.compliance_posture`` and served by
+ ``GET /v1/status/compliance`` + ``ragctl compliance report``. It is the
+ machine-readable backing for the SOC 2 / GDPR control-mapping docs: each control
+ maps to a Trust Service Criterion, and this reports whether it is on in *this*
+ deployment. Carries no tenant data — only configuration booleans — so it is
+ safe on the status surface.
+ """
+
+ model_config = {"frozen": True}
+
+ region: str = "" # the deployment's declared data region (GDPR residency)
+ controls: dict[str, bool] = Field(default_factory=dict)
+ enabled_controls: tuple[str, ...] = Field(default_factory=tuple)
+ retention: RetentionPolicy = Field(default_factory=RetentionPolicy)
+ generated_at: datetime = Field(default_factory=_utcnow)
diff --git a/packages/observability/src/rag_observability/events.py b/packages/observability/src/rag_observability/events.py
index 33a0447..5068e84 100644
--- a/packages/observability/src/rag_observability/events.py
+++ b/packages/observability/src/rag_observability/events.py
@@ -43,6 +43,7 @@
"DriftEvent",
"SsoEvent",
"ScimEvent",
+ "ComplianceEvent",
# event name constants — ingest
"EVT_INGEST_STARTED",
"EVT_INGEST_COMPLETED",
@@ -80,6 +81,10 @@
"EVT_SCIM_USER_PROVISIONED",
"EVT_SCIM_USER_DEPROVISIONED",
"EVT_SCIM_GROUP_CHANGED",
+ # event name constants — compliance (Step 6.10)
+ "EVT_COMPLIANCE_DATA_PURGED",
+ "EVT_COMPLIANCE_TENANT_ERASED",
+ "EVT_COMPLIANCE_RESIDENCY_VIOLATION",
# utilities
"check_pii",
]
@@ -191,6 +196,11 @@ def _register(cls, name: str) -> str:
EVT_SCIM_USER_DEPROVISIONED: str = RagEvent._register("scim.user_deprovisioned")
EVT_SCIM_GROUP_CHANGED: str = RagEvent._register("scim.group_changed")
+# Compliance (Step 6.10) — retention purge / GDPR erasure / residency enforcement
+EVT_COMPLIANCE_DATA_PURGED: str = RagEvent._register("compliance.data_purged")
+EVT_COMPLIANCE_TENANT_ERASED: str = RagEvent._register("compliance.tenant_erased")
+EVT_COMPLIANCE_RESIDENCY_VIOLATION: str = RagEvent._register("compliance.residency_violation")
+
# ---------------------------------------------------------------------------
# Typed event subtypes
@@ -398,3 +408,23 @@ class ScimEvent(RagEvent):
resource_type: str = "" # "User" | "Group"
resource_id: str = "" # server-assigned SCIM id (opaque UUID)
active: bool | None = None # the resulting active state, for User mutations
+
+
+class ComplianceEvent(RagEvent):
+ """Emitted on a compliance action — retention purge / erasure / residency (Step 6.10).
+
+ Carries only the operation, counts, and region labels — never any purged
+ record content / subject data — so compliance telemetry is naturally PII-free.
+ ``compliance.data_purged`` (per retention sweep that removed ≥1 record),
+ ``compliance.tenant_erased`` (a GDPR right-to-erasure run), and
+ ``compliance.residency_violation`` (an ingest refused because a tenant's region
+ did not match the deployment's). The tenant is tagged via
+ :attr:`RagEvent.tenant_id`.
+ """
+
+ operation: str = "" # "purge" | "erase" | "residency_violation"
+ purged_total: int = 0
+ data_classes: str = "" # comma-joined DataClass values touched
+ region: str = "" # the deployment region (residency)
+ tenant_region: str = "" # the tenant's required region (on a violation)
+ dry_run: bool = False
diff --git a/packages/ragctl/pyproject.toml b/packages/ragctl/pyproject.toml
index b013c36..4c84093 100644
--- a/packages/ragctl/pyproject.toml
+++ b/packages/ragctl/pyproject.toml
@@ -35,6 +35,7 @@ dependencies = [
"rag-drift>=0.1.0",
"rag-webhooks>=0.1.0",
"rag-sso>=0.1.0",
+ "rag-compliance>=0.1.0",
"rag-gateway>=0.4.0",
"httpx>=0.27",
]
@@ -65,6 +66,7 @@ rag-feedback = { workspace = true }
rag-drift = { workspace = true }
rag-webhooks = { workspace = true }
rag-sso = { workspace = true }
+rag-compliance = { workspace = true }
rag-gateway = { workspace = true }
[project.scripts]
diff --git a/packages/ragctl/src/ragctl/main.py b/packages/ragctl/src/ragctl/main.py
index b64ff2c..1b06451 100644
--- a/packages/ragctl/src/ragctl/main.py
+++ b/packages/ragctl/src/ragctl/main.py
@@ -5067,6 +5067,161 @@ def airgap_install(
typer.echo(f"installed release '{release}' in namespace '{namespace}'")
+# ---------------------------------------------------------------------------
+# Compliance posture (Step 6.10)
+# ---------------------------------------------------------------------------
+compliance_app = typer.Typer(
+ help="Report the compliance posture + demo data-retention / GDPR erasure (Step 6.10).",
+ no_args_is_help=True,
+)
+app.add_typer(compliance_app, name="compliance")
+
+
+@compliance_app.command("report")
+def compliance_report(
+ config: Path = typer.Option(
+ ...,
+ "--config",
+ "-f",
+ exists=True,
+ file_okay=True,
+ dir_okay=False,
+ readable=True,
+ help="Path to the rag.yaml to report the posture of.",
+ ),
+) -> None:
+ """Print the live compliance posture from ``rag.yaml`` (the SOC 2 / GDPR mapping).
+
+ Shows which governance controls are enabled, the deployment data region, and
+ the retention windows — the same posture ``GET /v1/status/compliance`` serves.
+ """
+ try:
+ cfg = load(config)
+ except ConfigError as exc:
+ typer.echo(f"ERROR: {exc.message}", err=True)
+ raise typer.Exit(1) # noqa: B904
+
+ from rag_compliance import compliance_posture
+ from rag_core.types import RetentionPolicy
+
+ r = cfg.compliance.retention
+ policy = RetentionPolicy(
+ chunks_days=r.chunks_days,
+ feedback_days=r.feedback_days,
+ provenance_days=r.provenance_days,
+ traces_days=r.traces_days,
+ audit_days=r.audit_days,
+ )
+ posture = compliance_posture(
+ region=cfg.compliance.region,
+ controls={
+ "audit": cfg.audit.enabled,
+ "acl": cfg.acl.enabled,
+ "pii": cfg.pii.enabled,
+ "byok": cfg.kms.enabled,
+ "sso": cfg.sso.enabled,
+ "scim": cfg.scim.enabled,
+ "quotas": cfg.quotas.enabled,
+ "guard": cfg.guard.enabled,
+ "breakers": cfg.breakers.enabled,
+ "retention": cfg.compliance.enabled,
+ "residency": cfg.compliance.enabled and bool(cfg.compliance.region),
+ },
+ retention=policy,
+ )
+
+ typer.echo("\ncompliance posture")
+ typer.echo("─" * 64)
+ typer.echo(f" data region: {posture.region or '(none declared)'}")
+ typer.echo(f" controls on: {', '.join(posture.enabled_controls) or '(none)'}")
+ off = sorted(name for name, on in posture.controls.items() if not on)
+ typer.echo(f" controls off: {', '.join(off) or '(none)'}")
+ windows = {
+ cls: days
+ for cls, days in (
+ ("chunks", policy.chunks_days),
+ ("feedback", policy.feedback_days),
+ ("provenance", policy.provenance_days),
+ ("traces", policy.traces_days),
+ ("audit", policy.audit_days),
+ )
+ if days is not None
+ }
+ typer.echo(
+ " retention: "
+ + (", ".join(f"{c}={d}d" for c, d in windows.items()) or "(retain indefinitely)")
+ )
+
+
+@compliance_app.command("demo")
+def compliance_demo(
+ tenant: str = typer.Option("acme", "--tenant", help="Tenant to run the demo for."),
+) -> None:
+ """Demo Step 6.10 data retention + GDPR erasure over in-memory stores.
+
+ Seeds aged feedback records, previews then applies an age-based retention
+ purge, then erases the whole tenant — showing the ``dry_run`` preview and the
+ tenant-scoped purge counts. No external services.
+
+ Example::
+
+ ragctl compliance demo --tenant acme
+ """
+ import asyncio
+ from datetime import UTC, datetime, timedelta
+
+ from rag_compliance import RetentionEnforcer
+ from rag_core.spi.noop import NoopFeedbackStore
+ from rag_core.types import (
+ FeedbackRecord,
+ FeedbackSignal,
+ Principal,
+ PrincipalId,
+ PrincipalKind,
+ RequestContext,
+ RequestId,
+ RetentionPolicy,
+ TenantId,
+ )
+
+ now = datetime(2026, 6, 8, tzinfo=UTC)
+ tid = TenantId(tenant)
+ ctx = RequestContext(
+ tenant_id=tid,
+ principal=Principal(
+ id=PrincipalId("cli"), kind=PrincipalKind.service, display_name="cli", tenant_id=tid
+ ),
+ )
+ store = NoopFeedbackStore()
+ enforcer = RetentionEnforcer(feedback_store=store, clock=lambda: now)
+
+ async def _run() -> None:
+ for i, age in enumerate((1, 45, 100, 200)):
+ await store.put(
+ ctx,
+ FeedbackRecord(
+ feedback_id=f"f{i}",
+ request_id=RequestId(f"f{i}"),
+ tenant_id=tid,
+ signal=FeedbackSignal.thumb_up,
+ created_at=now - timedelta(days=age),
+ ),
+ )
+ policy = RetentionPolicy(feedback_days=30)
+ typer.echo(f"\ndata retention + erasure — tenant={tenant}")
+ typer.echo("─" * 64)
+ typer.echo(f" seeded: {len(await store.list(ctx))} feedback records")
+ preview = await enforcer.purge(ctx, policy, dry_run=True)
+ still = len(await store.list(ctx))
+ typer.echo(f" purge (dry): would remove {preview.total} (still {still})")
+ applied = await enforcer.purge(ctx, policy)
+ typer.echo(f" purge: removed {applied.total} (now {len(await store.list(ctx))})")
+ erased = await enforcer.erase_tenant(ctx)
+ typer.echo(f" erase tenant: removed {erased.total} (now {len(await store.list(ctx))})")
+
+ asyncio.run(_run())
+
+
# ---------------------------------------------------------------------------
# Scaffold sub-apps — print a "delivered in Step X.Y" notice and exit 0.
#
diff --git a/packages/ragctl/tests/test_compliance.py b/packages/ragctl/tests/test_compliance.py
new file mode 100644
index 0000000..3613b67
--- /dev/null
+++ b/packages/ragctl/tests/test_compliance.py
@@ -0,0 +1,50 @@
+"""Tests for ``ragctl compliance`` — Step 6.10.
+
+``compliance demo`` runs the retention + erasure flow over in-memory stores;
+``compliance report`` prints the posture from a rag.yaml. No infrastructure.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from ragctl.main import app
+from typer.testing import CliRunner
+
+runner = CliRunner()
+
+
+def test_compliance_demo_retention_and_erasure() -> None:
+ result = runner.invoke(app, ["compliance", "demo", "--tenant", "acme"])
+ assert result.exit_code == 0, result.output
+ assert "seeded: 4 feedback records" in result.output
+ assert "would remove 3 (still 4)" in result.output # dry-run preview
+ assert "purge: removed 3 (now 1)" in result.output
+ assert "erase tenant: removed 1 (now 0)" in result.output
+
+
+def test_compliance_report_from_config(tmp_path: Path) -> None:
+ cfg = tmp_path / "rag.yaml"
+ cfg.write_text(
+ """
+version: "1"
+compliance:
+ enabled: true
+ region: eu-west-1
+ retention:
+ feedback_days: 30
+ provenance_days: 90
+audit:
+ enabled: true
+acl:
+ enabled: true
+""",
+ encoding="utf-8",
+ )
+ result = runner.invoke(app, ["compliance", "report", "-f", str(cfg)])
+ assert result.exit_code == 0, result.output
+ assert "data region: eu-west-1" in result.output
+ assert "acl, audit" in result.output # enabled controls
+ assert "retention" in result.output
+ assert "feedback=30d" in result.output
+ assert "provenance=90d" in result.output
diff --git a/pyproject.toml b/pyproject.toml
index 5a43233..403c4ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ members = [
"packages/agent",
"packages/webhooks",
"packages/sso",
+ "packages/compliance",
"apps/gateway",
"sdks/python",
]
@@ -214,7 +215,7 @@ ignore_errors = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests", "packages"]
-pythonpath = ["packages/core/src", "packages/config/src", "packages/observability/src", "packages/policy/src", "packages/ragctl/src", "packages/backends/src", "packages/parsers/src", "packages/ocr/src", "packages/chunker/src", "packages/enricher/src", "packages/pii/src", "packages/embedders/src", "packages/ingest/src", "packages/retrieval/src", "packages/query/src", "packages/reranker/src", "packages/packer/src", "packages/graphrag/src", "packages/cache/src", "packages/guard/src", "packages/breaker/src", "packages/quota/src", "packages/provenance/src", "packages/feedback/src", "packages/drift/src", "packages/agent/src", "packages/webhooks/src", "packages/sso/src", "apps/gateway/src", "."]
+pythonpath = ["packages/core/src", "packages/config/src", "packages/observability/src", "packages/policy/src", "packages/ragctl/src", "packages/backends/src", "packages/parsers/src", "packages/ocr/src", "packages/chunker/src", "packages/enricher/src", "packages/pii/src", "packages/embedders/src", "packages/ingest/src", "packages/retrieval/src", "packages/query/src", "packages/reranker/src", "packages/packer/src", "packages/graphrag/src", "packages/cache/src", "packages/guard/src", "packages/breaker/src", "packages/quota/src", "packages/provenance/src", "packages/feedback/src", "packages/drift/src", "packages/agent/src", "packages/webhooks/src", "packages/sso/src", "packages/compliance/src", "apps/gateway/src", "."]
# spi_signature.py is the SPI signature linter (Step 1.1a); referenced by name in
# docs/architecture/request-context.md. Collected alongside test_*.py files.
# coverage.py is the PolicyEngine coverage linter (Step 1.1c).
diff --git a/tests/contract/test_retention_purge.py b/tests/contract/test_retention_purge.py
new file mode 100644
index 0000000..0ddd93b
--- /dev/null
+++ b/tests/contract/test_retention_purge.py
@@ -0,0 +1,109 @@
+"""Conformance suite for the retention-purge SPI methods (Step 6.10).
+
+Exercises ``purge_before`` (age-based retention) and ``purge_tenant`` (GDPR
+right-to-erasure) on the in-memory Feedback + Provenance stores: tenant-scoped,
+age-correct, and isolated.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from rag_core.spi.noop import NoopFeedbackStore, NoopProvenanceStore
+from rag_core.types import (
+ FeedbackRecord,
+ FeedbackSignal,
+ ProvenanceRecord,
+ RequestContext,
+ RequestId,
+ SignedProvenanceRecord,
+)
+
+pytestmark = pytest.mark.contract
+
+_NOW = datetime(2026, 6, 8, tzinfo=UTC)
+
+
+def _feedback(ctx: RequestContext, fid: str, *, age_days: int) -> FeedbackRecord:
+ return FeedbackRecord(
+ feedback_id=fid,
+ request_id=RequestId(fid),
+ tenant_id=ctx.tenant_id,
+ signal=FeedbackSignal.thumb_up,
+ created_at=_NOW - timedelta(days=age_days),
+ )
+
+
+def _provenance(ctx: RequestContext, rid: str, *, age_days: int) -> SignedProvenanceRecord:
+ return SignedProvenanceRecord(
+ record=ProvenanceRecord(
+ request_id=RequestId(rid),
+ trace_id=rid,
+ tenant_id=ctx.tenant_id,
+ created_at=_NOW - timedelta(days=age_days),
+ )
+ )
+
+
+async def test_feedback_purge_before_age(ctx: RequestContext) -> None:
+ store = NoopFeedbackStore()
+ await store.put(ctx, _feedback(ctx, "old", age_days=100))
+ await store.put(ctx, _feedback(ctx, "new", age_days=1))
+
+ cutoff = _NOW - timedelta(days=30)
+ purged = await store.purge_before(ctx, cutoff)
+ assert purged == 1
+ remaining = await store.list(ctx)
+ assert [r.feedback_id for r in remaining] == ["new"]
+
+
+async def test_feedback_purge_tenant_erasure(
+ ctx: RequestContext, other_ctx: RequestContext
+) -> None:
+ store = NoopFeedbackStore()
+ await store.put(ctx, _feedback(ctx, "a", age_days=1))
+ await store.put(ctx, _feedback(ctx, "b", age_days=1))
+ await store.put(other_ctx, _feedback(other_ctx, "c", age_days=1))
+
+ purged = await store.purge_tenant(ctx)
+ assert purged == 2
+ assert await store.list(ctx) == []
+ # Other tenant untouched (erasure is tenant-scoped).
+ assert len(await store.list(other_ctx)) == 1
+
+
+async def test_feedback_purge_is_tenant_scoped(
+ ctx: RequestContext, other_ctx: RequestContext
+) -> None:
+ store = NoopFeedbackStore()
+ await store.put(ctx, _feedback(ctx, "mine", age_days=100))
+ await store.put(other_ctx, _feedback(other_ctx, "theirs", age_days=100))
+ # Purging ctx's old records never touches another tenant's.
+ assert await store.purge_before(ctx, _NOW) == 1
+ assert len(await store.list(other_ctx)) == 1
+
+
+async def test_provenance_purge_before_and_tenant(
+ ctx: RequestContext, other_ctx: RequestContext
+) -> None:
+ store = NoopProvenanceStore()
+ await store.put(ctx, _provenance(ctx, "old", age_days=200))
+ await store.put(ctx, _provenance(ctx, "new", age_days=1))
+ await store.put(other_ctx, _provenance(other_ctx, "x", age_days=200))
+
+ assert await store.purge_before(ctx, _NOW - timedelta(days=90)) == 1
+ assert await store.get(ctx, RequestId("new")) is not None
+ assert await store.get(ctx, RequestId("old")) is None
+ # Other tenant's old record survives ctx's purge.
+ assert await store.get(other_ctx, RequestId("x")) is not None
+
+ assert await store.purge_tenant(ctx) == 1
+ assert await store.get(ctx, RequestId("new")) is None
+
+
+async def test_purge_default_noop_on_base() -> None:
+ # The ABC default returns 0 (backward-compatible for non-retention backends).
+ from rag_core.spi.feedback_store import FeedbackStore
+
+ assert FeedbackStore.purge_tenant.__doc__ is not None # method exists on the ABC
diff --git a/tests/logs/test_event_schema.py b/tests/logs/test_event_schema.py
index 87f15f5..f7ef0a7 100644
--- a/tests/logs/test_event_schema.py
+++ b/tests/logs/test_event_schema.py
@@ -13,6 +13,9 @@
EVT_CACHE_HIT,
EVT_CACHE_INVALIDATED,
EVT_CACHE_MISS,
+ EVT_COMPLIANCE_DATA_PURGED,
+ EVT_COMPLIANCE_RESIDENCY_VIOLATION,
+ EVT_COMPLIANCE_TENANT_ERASED,
EVT_DRIFT_DETECTED,
EVT_FALLBACK_ENGAGED,
EVT_FEEDBACK_RECORD_DEGRADED,
@@ -37,6 +40,7 @@
EVT_SSO_AUTHENTICATED,
BreakerEvent,
CacheEvent,
+ ComplianceEvent,
GuardEvent,
IngestEvent,
PiiEvent,
@@ -79,6 +83,9 @@
EVT_SCIM_USER_PROVISIONED,
EVT_SCIM_USER_DEPROVISIONED,
EVT_SCIM_GROUP_CHANGED,
+ EVT_COMPLIANCE_DATA_PURGED,
+ EVT_COMPLIANCE_TENANT_ERASED,
+ EVT_COMPLIANCE_RESIDENCY_VIOLATION,
]
@@ -423,3 +430,37 @@ def test_carries_no_identifying_attributes(self) -> None:
active=False,
)
assert check_pii(evt.model_dump_json()) == []
+
+
+class TestComplianceEvent:
+ def test_valid_compliance_event(self) -> None:
+ evt = ComplianceEvent(
+ event_name=EVT_COMPLIANCE_DATA_PURGED,
+ tenant_id="acme",
+ operation="purge",
+ purged_total=12,
+ data_classes="feedback,provenance",
+ )
+ assert evt.operation == "purge"
+ assert evt.purged_total == 12
+
+ def test_residency_violation_is_pii_free(self) -> None:
+ for field in ("subject", "email", "document", "query", "user_name"):
+ assert field not in ComplianceEvent.model_fields
+ evt = ComplianceEvent(
+ event_name=EVT_COMPLIANCE_RESIDENCY_VIOLATION,
+ tenant_id="acme",
+ operation="residency_violation",
+ region="us-east-1",
+ tenant_region="eu-west-1",
+ )
+ assert check_pii(evt.model_dump_json()) == []
+
+ def test_erasure_event(self) -> None:
+ evt = ComplianceEvent(
+ event_name=EVT_COMPLIANCE_TENANT_ERASED,
+ tenant_id="acme",
+ operation="erase",
+ purged_total=5,
+ )
+ assert evt.operation == "erase"
diff --git a/uv.lock b/uv.lock
index 439abbf..909a4fc 100644
--- a/uv.lock
+++ b/uv.lock
@@ -26,6 +26,7 @@ members = [
"rag-breaker",
"rag-cache",
"rag-chunker",
+ "rag-compliance",
"rag-config",
"rag-core",
"rag-drift",
@@ -7319,6 +7320,32 @@ requires-dist = [
]
provides-extras = ["dev"]
+[[package]]
+name = "rag-compliance"
+version = "0.1.0"
+source = { editable = "packages/compliance" }
+dependencies = [
+ { name = "rag-core" },
+ { name = "rag-observability" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "mypy" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0" },
+ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3" },
+ { name = "rag-core", editable = "packages/core" },
+ { name = "rag-observability", editable = "packages/observability" },
+]
+provides-extras = ["dev"]
+
[[package]]
name = "rag-config"
version = "0.3.0"
@@ -7507,6 +7534,7 @@ dependencies = [
{ name = "rag-breaker" },
{ name = "rag-cache" },
{ name = "rag-chunker" },
+ { name = "rag-compliance" },
{ name = "rag-config" },
{ name = "rag-core" },
{ name = "rag-drift" },
@@ -7551,6 +7579,7 @@ requires-dist = [
{ name = "rag-breaker", editable = "packages/breaker" },
{ name = "rag-cache", editable = "packages/cache" },
{ name = "rag-chunker", editable = "packages/chunker" },
+ { name = "rag-compliance", editable = "packages/compliance" },
{ name = "rag-config", editable = "packages/config" },
{ name = "rag-core", editable = "packages/core" },
{ name = "rag-drift", editable = "packages/drift" },
@@ -7920,6 +7949,7 @@ dependencies = [
{ name = "rag-backends" },
{ name = "rag-breaker" },
{ name = "rag-chunker" },
+ { name = "rag-compliance" },
{ name = "rag-config" },
{ name = "rag-core" },
{ name = "rag-drift" },
@@ -7952,6 +7982,7 @@ requires-dist = [
{ name = "rag-backends", editable = "packages/backends" },
{ name = "rag-breaker", editable = "packages/breaker" },
{ name = "rag-chunker", editable = "packages/chunker" },
+ { name = "rag-compliance", editable = "packages/compliance" },
{ name = "rag-config", editable = "packages/config" },
{ name = "rag-core", editable = "packages/core" },
{ name = "rag-drift", editable = "packages/drift" },