How to use: Read this first in every session to know exactly where the build stands. When a step lands on
main: flip its Status + PR, add a row to PR & Branch History, and refresh the Status block.
Standing requirement (all steps): Every deliverable — scripts, tooling, CI, dev-stack — must work on Windows, macOS, and Linux without WSL. Task targets via
Taskfile.yml(cross-platform) +Makefile(Unix convenience). CI matrix always includesubuntu-22.04,macos-14, andwindows-latest.
| Last updated | 2026-06-19 |
| Current phase | Phase 7 — Pilot, Harden, GA (10 / 10 steps ✅) — all phases complete |
| Overall | 84 / 84 steps ✅ — v1.0.0 GA |
| Next action | 🎉 GA reached. Engineering scope (Phases 0–7) complete; v1.0.0 cut. Remaining items are external/process (external pentest, SOC 2 audit, registry first-publish, signed referenceable customers, on-call staffing + status page, launch assets) — tracked in the GA readiness checklist. Post-GA: cut the v1.0.1 patch tag (serve-path fix below; note: GitHub Actions runs are currently blocked by an account billing/spending-limit failure — every docker.yml run since GA failed at job start, so the GHCR image was never actually published), then the V1.1 backlog. V1.1 underway: Step 3.10 corpus admin CRUD landed (#189); a follow-on post-GA batch in #194 adds corpus/webhook PATCH (completes the 3.10 update path), admin-UI bug fixes, real-backend (OpenAI LLM + content hydration) wiring, and the local Docker/demo stack. |
Recently shipped
-
post-GA batch ✅ (V1.1, #194) — corpus/webhook PATCH completes the Step 3.10 CRUD update path (
CorpusStore/SubscriptionStore.update— SPI + Noop + Pg;PATCH /v1/corpora/{id}+PATCH /v1/webhooks/subscriptions/{id}; embedding model/dim immutable, signing secret stays masked) + admin-UI bug fixes (BUG-002/003/004/007/008/009 — Edit dialogs, per-source demo badges, metrics hydration, stale copy) + real-backend wiring (config-drivenOpenAILLM+ content hydration so ingested docs are retrievable end-to-end) + a local Docker app stack (pg + gateway + admin-ui), branding, and GTM demo assets. Verified locally (CI billing-blocked):ruff/mypy --strict(333 files) / fullpytest/ proto·openapi·schema drift + admin-uitsc·eslint·46 vitest·next buildall green. Testing-findings tracker added at BUGS.md (BUG-005 audit-coverage + BUG-006 ingest doc-count remain open, root-caused). #194 -
3.10 ✅ (V1.1) Corpus admin CRUD — the console can now create / delete corpora against the gateway (was local-only React state with no write route —
POST /v1/corpora405'd, so "created" corpora never persisted).CorpusStoreSPI gains tenant-scopedcreate/delete(Noop + Pg; same cross-tenant invisibility rule asget); gatewayPOST /v1/corpora(201, server-mintedcorpus_…id, tenant fromctx) +DELETE /v1/corpora/{id}(204/404) + aCreateCorpusRequestwire type; admin-uicreateCorpus/deleteCorpuswired into the Corpora page (live→local-demo gate, mirrors Webhooks). Update (PATCH) followed post-GA in #194 — the Edit action is now wired end-to-end (corpus + webhook). Verified locally (CI billing-blocked): full create→list→delete→404 + tenant isolation + CORS preflight/POST from:3100;pytest/ruff/mypy --strict/ admin-uitsc+vitest+ OpenAPI·schema drift all green. #189 -
7.9b ✅ Admin-ui Operate pages — the console can now drive the request path, not just observe it (closes the three High-priority gaps from the v2 API-coverage analysis; built to the Operate design brief, 1:1 on the real wire types in
rag_core.gateway_types/rag_core.openai_types). New Operate nav group + three pages: Query Playground (/playground— fullQueryRequestcontrol rail: corpus multiselect /top_k/ rerank·pack·generate switches / advancedpack_budget·generate_max_tokens·generate_temperature·filters; Run queryPOST /v1/queryvs Retrieve onlyPOST /v1/retrieve; results render the decision strip (shape · bm25-fallback · corpus strategy · packed tokens · experiment tag), the answer with clickable[n]citation chips + guard verdict, citations, the ranked-chunks table (trust badges, detail sheet), a stacked per-stage timings bar with skipped-stage markers, and a feedback bar (👍/👎/1–5★/copy/regenerate/PII-redacted comment →POST /v1/feedback, exact signal enum); View full trace deep-links to/trace?rid=…— the seeded run reusesDEMO_REQUEST_IDso the demo resolves end-to-end); Ingest (/ingest, Knowledge — drag-and-drop multipartPOST /v1/ingest/document, staged queue with progress →IngestResultchunk/embedding badges or error+Retry, the parse → chunk → enrich → PII-redact → embed → index explainer); Chat (/chat— OpenAI-compatible: streaming SSE thread with the non-standardragannotations surfaced per reply (shape/chunks/citations/guard/finish_reason/usage; RAG on by default), an Embeddings tab (one input per line, float/base64, dim + L2 norm + sparkline), and a Models tab (GET /v1/models)). All follow the live→seed hybrid: action surfaces simulate without a gateway (deterministic worked query, token-streamed replies honouringprefers-reduced-motion). NewSlider/Tabsprimitives +CorpusMultiSelect/GuardBadge/CitedTextshared components; identity body-carried on/v1/query·/v1/retrieve·/v1/feedback, header-driven elsewhere. Gates: tsc / eslint / 46 vitest (12 new) /next build(17 static routes); browser-verified light+dark, all three pages + the trace deep-link. reference/admin-ui.md -
v1.0.1 ✅ (patch) — production serve path fixed end-to-end. The image's default CMD (a Phase-0 placeholder that printed the version and exited) now serves via the new config-driven
rag_gateway.serve:create_appuvicorn-factory entrypoint:RAG_CONFIG_PATHset →build_app_from_config(load(path)), unset → noopbuild_app(), set-but-broken → fail fast (ConfigError), never a silent noop fallback. The image build itself was also broken — the fulluv syncneeds every workspace member on disk butsdks/pythonwas never copied (Distribution not found); both stages now copy it. New publicGET /readyzbacks the Helm chart's readiness probe (liveness stays/healthz). The chart's ConfigMap previously rendered an invalidrag.yaml(env:/log_level:/service:against the strictextra='forbid'schema → guaranteed CrashLoop); it now renders a minimal valid document with aconfig.ragYamlpassthrough for full platform configs (${VAR}env interpolation keeps secrets out of the ConfigMap), chart0.2.0/appVersion1.0.1(the old0.1.0appVersion pointed the default image tag at a tag that never existed),values.dev.yamlenv: development→local. Docs: image tags are published without thevprefix —:v1.0.0-style pulls in README/release-notes never matched. release notes #185 -
7.10 ✅ GA cutover — v1.0.0 🎉 — the close of an 8-phase, 84-step build. Cut
v1.0.0with Semantic Versioning over the drift-gated public contracts (REST/OpenAPI · gRPC proto ·rag.yaml· SDKs ·ragctl) + a deprecation policy (ADR-0052); therag-platformmeta bumped to 1.0.0. GA release notes (v1.0.0) + a GA readiness checklist (ga-readiness-checklist) that maps every Phase 7 exit gate to a CI gate / in-repo artifact, with inherently-external items (external pentest, SOC 2 audit, registry first-publish, signed customers, on-call staffing, launch assets) marked (external) — the same in-repo-machine vs external-deliverable split used across Phase 7. Thev1.0.0tag fans out to every channel viadocker.yml+release.yml+release-airgap.yml. -
7.9 ✅ Billing & metering — per-tenant usage accounting, mirroring the 5.6c cost pattern (dataclasses in
rag-observability, the gateway wraps a Pydantic response → nodist/schemaschurn). Newrag_observability.billing: aUsageMeter(observe-only per-tenant, per-dimension counters: queries / docs / storage / reranker / LLM tokens) fed O(1) fromrecord_request_usageindependent of quotas;GET /v1/billing/usageserves the per-tenant dashboard; puregenerate_invoice+PricingModel/load_pricingprice an invoice frommarketplace/pricing.yaml(a price change is config) and reconcile with metered usage ±0.5% by construction; aBillingProviderProtocol seam for Stripe / marketplace metering (degrade-open). Inert by default (cfg.billing);ragctl billingdrives meter→invoice→reconcile; no governed SPI call (policy-coverage linter unchanged); onlydist/openapi+dist/rag.schemaregenerate. Deferred: the concrete Stripe/marketplace adapters, the docs/reranker/storage feed points, an admin usage card. ADR-0051 #182 -
7.8 ✅ Support, SLAs & on-call — the support model + ops runbooks, grounded in the platform's own signals. Four support tiers + per-tier SLA targets (matching
marketplace/pricing.yaml) insupport-sla.md; a newdocs/runbooks/area with one runbook per alert type (alerts.md— acl/tenant escape ·breaker.opened·drift.detected· cost anomaly ·quota.exceeded· injection spike · gateway degraded · latency/availability SLO ·eval.regression· ingest), each naming the exact event //v1/status/*/ SLO that raises it; an incident-response process + a blameless postmortem template; the status page reads measured health (/v1/status/health+ Grafana SLOs), not manual toggles. Paging reuses the Step 3.9 webhook system (PagerDuty as a subscriber — no new code). Deferred: hosted status-page deploy, the PagerDuty account, staffing. ADR-0050 #181 -
7.7 ✅ Packaging & distribution — one-command install per channel + one tag fans out to every channel. New
rag-platformPyPI meta-package (packaging/rag-platform/) pins the workspace component dists for a reproduciblepip install rag-platform; the existingagentcontextosSDK scope is kept (no rename). A newrelease.ymlpublishes PyPI (OIDC Trusted Publishing) + npm (@agentcontextos/sdk, provenance) + the admin-ui image (newapps/admin-ui/Dockerfile, cosign + SBOM) + the Helm chart → OCI (cosign), alongside the existingdocker.yml(gateway image, cosign + SBOM) andrelease-airgap.yml— all keyed off the samevX.Y.Ztag. Deferred: Go/Java/.NET SDK publish, worker/eval-runner images (single-process today). ADR-0049 #180 -
7.6 ✅ Marketplace listings — listing artifacts for AWS / Azure / GCP under a new
marketplace/(approval + procurement stay an external per-cloud process, like the 7.3 pentest). One canonicalpricing.yaml(tiers + usage-metered dimensions whose ids match the Step 7.9 metering signals) + one sharedlisting-content.mdmapped to all three clouds, so listings can't drift and prices reconcile with metering. Each per-cloud spec maps the model to that cloud's billing primitives and reuses existing delivery (Helm / AMI / air-gap bundle / GHCR — no new packaging); per-cloud submission checklists + a procurement acceptance test in the guide. ADR-0048 #179 -
7.5 ✅ Documentation site — a Docusaurus 3 app (
website/) that serves the repodocs/tree in place (single source of truth;markdown.format: 'detect'renders hand-written.mdas CommonMark). The REST API reference (docs/reference/rest-api.md) is generated + drift-gated fromdist/openapi.jsonbyscripts/gen_api_reference.py(task docs:api) — never hand-edited. Doc honesty is atests/docs/suite (everyragctl//v1/reference in a quickstart must be real) +lycheeoffline link-check +codespell, wired into a newdocsCI workflow. Deferred: Algolia search, versioned docs, the deploy workflow. ADR-0047 #178 -
7.4 ✅ Design-partner pilots — the pilot program shipped as a repo-backed machine (signing the referenceable partners is the external GTM deliverable, like the 7.3 pentest): an operator runbook + five fill-in templates (
docs/pilots/), a customer-support/KB kit (docs/pilots/customer-support/— sample corpus with synthetic PII + a planted injection probe; domain-calibrated criteria), andragctl pilot(onboardrenders the per-tenant config + checklist;reportassembles a weekly-KPI dashboard + PASS/FAIL verdict from the platform's own feedback / drift / cost signal components — the same ones behindGET /v1/status/*). KPIs are pulled, not self-reported. A worked case study runs it end-to-end on the kit with realragctl pilot reportoutput (satisfaction +0.733, 0/5 drift, cost ok → PASS) + the PII-redaction + injection-blocked security demo. Nodist/SPI/config change. #174 -
7.3 ✅ Red-team / security — turns the governance stack into an adversarial probe gate across four classes (prompt injection / PII egress / ACL bypass / tenant escape) under a
redteammarker + a first-classredteam-gateCI job (task redteam). Closes the verified injection gap: newrag-injectionpackage — a pluggableInjectionDetector(dependency-freeHeuristicInjectionDetector, regexes anchored on attack grammar so benign prose isn't flagged) +PromptInjectionGuard.inspectthat drops hijack chunks before the LLM, paired withINJECTION_RESISTANT_SYSTEM_PROMPT+build_user_messageso untrusted context is fenced data in the user turn, never a system-trust position (fixes the OpenAI-chat surface that put context in asystemmessage); wired on/v1/query+/v1/chat/completions+ MCP; off by default (cfg.injection); PII-freeinjection.blockedevent. Deterministic gate: a ≥ 500 known + ≥ 500 generated corpus (eval/redteam_v0/) hits 96.6 % block (≥ 95 % bar) at 0 false positives, plus the no-system-position invariant end-to-end — building the corpus hardened the detector (~20 missed phrasings). New PII-egress probe overPiiPolicyEngine(zero leakage, second-detector verified);pip-auditis the CVE gate; the external pentest is a documented process item. Injection types stay internal (no attacker signal,dist/schemas/openapiuntouched). #166 -
7.2 ✅ Chaos engineering — a deterministic in-process kill-matrix gate (
eval/gateway_chaos_v0/kill_matrix.py+tests/perf/test_chaos_kill_matrix.py) that extends 7.1 from the three retrieval backends to the full hot-path set (vector/keyword/graph/embedder/retrieval_cache/reranker/llm): kill each backend in turn (100% unavailable) behind the real breakers + fallback, drive/v1/query, and assert no single failure 5xx-es the gateway (no 5xx, on-path retrieval breaker opens, expected degraded shape; a seeded keyword corpus + realhydratemake rerank/generate actually run). Chaos fixed what it found — the matrix exposed that a down retrieval cache or reranker 5xx-ed, so the gateway gained two minimal degrade-open guards (gateway.cache.degraded→ miss on/v1/query+/v1/retrieve;gateway.rerank.degraded→ retrieval-only, honouringRerankPipeline's "caller decides" contract); LLM + embedder already degraded. LitmusChaos cluster manifests (infra/chaos/: gateway pod-delete + backendpod-network-loss/latencywith httpProbe acceptance) as the cluster runbook +task chaos-kill. Nodist//SPI/config change. #165 -
7.1 ✅ Load + chaos testing — a chaos-under-load CI gate (
eval/gateway_chaos_v0/): drives the in-process gateway under concurrent load while injecting backend faults (FaultSpec+Chaos{Vector,Keyword,Graph}RetrievalBackendSPI wrappers behind real breakers) and asserts graceful degradation — no 5xx, 100% success, the failing backend's breaker opens (validating the Phase-4 breakers + fallback, builds no new resilience);tests/perf/test_chaos_under_load.py(timing-independent,perf-marked) +task chaos-test. An extended Locust v1 suite (weighted read/write mix + varied queries + a rampLoadTestShape) + documented acceptance targets (≥ 1000 RPS sustained, e2e p99 < 500 ms) as a cluster runbook — #164 -
6.10 ✅ Compliance posture (Phase 6 capstone) — new
rag-compliancepackage:RetentionEnforcerdrives tenant-scopedpurge_*(data retention + GDPR right-to-erasure) over the Feedback/Provenance stores (non-abstractpurge_before/purge_tenantSPI capability withdry_runso 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-tenantdata_regionenforced at ingest (ResidencyViolationError→ 403);GET /v1/status/complianceposture +compliance_posture/residency_ok; PII-freecompliance.*events;cfg.compliance+tenants[].data_region/retention_days;ragctl compliance report/demo; SOC 2 + GDPR control-mapping docs backed by the live posture — #162 -
6.9 ✅ Air-gapped install bundle —
ragctl airgap build/inspect/verify/install: one signed.tar.gzof all runtime images (docker save) + the packaged Helm chart +rag.yaml+ a standalone installer, for networks with no registry / internet. Integrity = a standardSHA256SUMS(verifiable withsha256sum -c, no cosign/network) pinned asmanifest.content_hash+ an optional cosign signature over it (the 6.6b content-hash + optional-signature pattern); the sameSHA256SUMSdrives the Python verifier and the standaloneinstall.{sh,ps1}(need only docker+helm). Typed/tested logic inragctl.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.ymlon tags — #161 -
6.8 ✅ SSO / SCIM —
rag-sso:FederatedAuth(anAuthSPI backend over per-tenantOidcProvider/SamlProvider) federates a bearer token / SAML assertion →Principalat the existingauthenticateseam (group claims →acl_labels); dependency-free defaults (stdlib HS256 JWT +defusedxmlSAML), asymmetric OIDC / XML-DSig behind[oidc]/[saml]extras; algorithm-allowlist downgrade defense; SCIM 2.0ScimServiceover the new tenant-scopedScimStoreSPI driving/scim/v2/{Users,Groups}(per-tenant bearer token); per-tenant IdP ontenants[].sso;cfg.sso/cfg.scim;GET /v1/status/sso; PII-freesso.*/scim.*events;ragctl sso/scim— #159 -
6.7 ✅ BYOK envelope encryption —
KeyManagerSPI +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–#158 -
6.6 ✅ Immutable audit log — 6.6a shared
AuditWriter/store onapp.state+GET /v1/audit(tenant-scoped,chain_verified) +GET /v1/audit/verify(whole-log) +cfg.audit.enabled(#153); 6.6bAuditExporterself-verifying WORM bundle (SHA-256content_hash+ HMAC, offlineverify()),POST /v1/audit/export(tenant-scoped) +ragctl audit,cfg.audit.export_secret(#154) -
6.5 ✅ PII egress policies —
PiiPolicyEngine(rag-pii) answersegress_textover the context (list[Chunk]) + agent answer (str) the gateway already passes, applying the per-tenantpii_policy(allow / redact / mask / block); reuses the Step 1.7 detector + rewriters; opt-incfg.pii.enabled; emitspii.egress_blocked— #152 -
6.4 ✅ ACL egress verifier —
AclEgressVerifierre-checks returnedChunkRefs against the principal's labels at the gateway router boundary (defense-in-depth, same overlap semantics, independent of the PDP);acl.egress_violationevent; red-team zero-violation-rate gate — #151 -
6.3 ✅ ACL push-down — opt-in
AclPolicyEngineAnd-mergesany_in("acl_labels", principal.acl_labels)into everyread_chunkquery (overlap, fail-closed);acl.egress_deniedevent — #150 -
6.2 ✅ Physical tenancy — per-tenant dedicated vector index/collection;
TenantConfig.dedicated_index→ctx.physical_index→ backend<base>-<key>(Noop/Pinecone/Qdrant); cross-tenant probe gate — #149 -
6.1 ✅ Logical multi-tenancy —
TenantResolverresolves per-tenantrag.yamlconfig (namespace / PII / ACL labels) →TenantSettingsapplied at the gateway boundary;RequestContext.namespace;GET /v1/status/tenant;ragctl tenant— #148 -
5.7d ✅ Experiments console card + Phase-5 close-out — admin Live-Status A/B card over
GET /v1/status/experiments(lift + CI per experiment) — #147 -
5.7c ✅ A/B routing —
ABRouterdeterministically serves the candidate to a fraction of users (variant-partitioned cache,ExperimentAssignmentresponse tag) — #146 -
5.7b ✅ Shadow mode — observe-only candidate fan-out (
ShadowRunner, background task) feeding the A/B tracker — #145 -
5.7a ✅ A/B analyzer + experiment tracker +
GET /v1/status/experimentsdashboard — #143 -
5.6 ✅ Status & Metrics GUI (full build) — drift/feedback/cost cards, query-trace viewer, regression bisector, Grafana dashboards, cross-links — #137–#142
-
5.5 ✅ Drift monitors —
rag-drift, five PSI / mean-drop monitors — #136 -
5.4 ✅ Online metrics & feedback —
rag-feedback— #135 -
5.3 ✅ CI eval gate — #134 · 5.2 ✅ Offline golden-set eval harness — #133 · 5.1 ✅ Per-query tracing & provenance — #132
Full per-step detail is in the phase sections below; each step links its ADR / reference / architecture docs.
| Symbol | Meaning |
|---|---|
| ✅ | Complete — PR merged to main |
| 🚧 | In Progress — branch open, PR exists |
| ⏳ | Not Started |
| 🔒 | Blocked — depends on incomplete step |
| Phase | Title | Steps | ✅ Done | Remaining |
|---|---|---|---|---|
| 0 | Foundation | 13 | 13 | 0 |
| 1 | Ingestion + Knowledge Store | 16 | 16 | 0 |
| 2 | Retrieval Engine | 11 | 11 | 0 |
| 3 | Gateway & Agent Runtime | 11 | 11 | 0 |
| 4 | Reliability | 6 | 6 | 0 |
| 5 | Eval & Observability | 7 | 7 | 0 |
| 6 | Governance & Tenancy | 10 | 10 | 0 |
| 7 | Pilot, Harden, GA | 10 | 10 | 0 |
| Total | 84 | 84 | 0 |
| Step | Title | Status | PR |
|---|---|---|---|
| 0.1 | Monorepo & build system | ✅ | #3 |
| 0.2 | Core domain types & errors | ✅ | #4 |
| 0.2c | Cross-platform tooling | ✅ | #6 |
| 0.3 | Plugin SPI | ✅ | #7 |
| 0.4 | rag.yaml schema & loader |
✅ | #8 |
| 0.5 | CI/CD pipeline hardening | ✅ | #9 |
| 0.6 | Local dev stack (Docker Compose) | ✅ | #26 |
| 0.7 | Tracing & metrics foundation | ✅ | #27 |
| 0.7b | Structured logging foundation | ✅ | #29, #30 |
| 0.7c | Audit log skeleton | ✅ | #31 |
| 0.8 | Eval skeleton | ✅ | #33 |
| 0.9 | IaC foundation | ✅ | #34 |
| 0.10 | ragctl CLI scaffold |
✅ | #36 |
0.1 — Monorepo & build system ✅ #3
pyproject.toml,Makefile,.pre-commit-config, CI workflowsdocker-compose, lockfiles- ADR-0001
0.2 — Core domain types & errors ✅ #4
types.py— 11 Pydantic v2 models ·errors.py— 14-type exception hierarchygen_schemas.py— JSON Schema export ·proto/core.proto
0.2c — Cross-platform tooling ✅ #6
Taskfile.yml— Windows / macOS / Linux task runnerscripts/bootstrap.ps1+bootstrap.sh; CI matrix expanded towindows-latest
0.3 — Plugin SPI ✅ #7
- 16 SPI ABCs in
rag_core/spi/: VectorStore, KeywordStore, GraphStore, Embedder, Reranker, LLM, Cache, Queue, Storage, Secrets, Auth, Telemetry, Connector, Parser, OCR, PIIDetector - Noop in-memory impls in
spi/noop/; 79 conformance tests intests/contract/
0.4 — rag.yaml schema & loader ✅ #8
- JSON Schema (
dist/rag.schema.json) + Pydantic v2 schema (packages/config/) - Env-var interpolation,
ragctl config validate, polling hot-reload - 91 fixture tests; 5 example configs
0.5 — CI/CD pipeline hardening ✅ #9
- Path-filtered jobs, dependency caching, Docker layer cache
- SBOM generation, cosign image signing, Dependabot
0.6 — Local dev stack (Docker Compose) ✅ #26
- Health-check scripts, seed-data loader,
make dev-reset - pgvector schema migration, Qdrant collection init
0.7 — Tracing & metrics foundation ✅ #27
- OTel SDK wired,
TraceContextpropagation SpiMetricscounter + histogram, Grafana provisioned dashboards,ragctl traces
- Shared logger package + event registry (
events.py); RAG001 pre-commit hook + ruffT201 - Schema + PII CI gates in
tests/logs/(50 tests; log-gates job blocking) - Follow-up: extracted
packages/observability/(rag-observability) — 7-field JSON schema (ts, level, service, module, msg, env, version),set_log_context()contextvar manager;rag_corereduced to back-compat shims (391 tests)
0.7c — Audit log skeleton ✅ #31
AuditStoreSPI (append / events / verify_chain) +NoopAuditStore(SHA-256 hash chain)AuditWriterfacade (store + structured log); 14 conformance tests
0.8 — Eval skeleton ✅ #33
rag_core.evaltypes (GoldenSample, EvalMetrics, EvalReport) +rag_config.evalmetrics (recall@k, MRR, citation_precision)- RagasAdapter spike,
ragctl eval run/show, 5-sample golden JSONL,tests/eval/harness (39 tests)
0.9 — IaC foundation ✅ #34
- Terraform modules for Postgres/pgvector, Redis, Qdrant, Elasticsearch (Kubernetes-native, Helm provider)
rag-platformHelm chart (Deployment, Service, ConfigMap, ServiceAccount, HPA, PDB, Ingress); dev + prod envstask infra:*+task helm:*; ADR-0003
0.10 — ragctl CLI scaffold ✅ #36
packages/ragctl/(Typer 0.12+), rootragctlentry point- Working
config/eval/traces/versiongroups; scaffoldedingest/query/logs/tenant/plugin/secret - Shell completion; 19 tests; reference/ragctl.md + guides/ragctl-quickstart.md
Refactor window (1.1a–1.1f): before resuming connectors, a six-step refactor locked in architecture decisions expensive to retrofit — PolicyEngine PDP, RequestContext-threaded SPIs, split Retrieval/Index backends, bulk + streaming + ID-only methods, Pipeline + Batcher primitives, three-way cache split, hot-path discipline.
| Step | Title | Status | PR |
|---|---|---|---|
| 1.1 | Storage backends | ✅ | #40 |
| 1.1a | Core type & SPI refactor | ✅ | #44 |
| 1.1b | SPI split — Retrieval/Index, bulk + streaming + ID-only | ✅ | #45 |
| 1.1c | PolicyEngine package | ✅ | #46 |
| 1.1d | Pipeline + Batcher primitives | ✅ | #50 |
| 1.1e | Cache SPI split + perf discipline + async telemetry | ✅ | #51 |
| 1.1f | ADRs 0005–0009 + reviewer checklist | ✅ | #52 |
| 1.2 | Connectors framework | ✅ | #55 |
| 1.3 | Document parsers | ✅ | #57 |
| 1.4 | OCR pipeline | ✅ | #60 |
| 1.5 | Structure-aware chunker | ✅ | #62 |
| 1.6 | Metadata enricher | ✅ | #64 |
| 1.7 | PII detection | ✅ | #66 |
| 1.8 | Embedder pipeline | ✅ | #68 |
| 1.9 | CDC connectors (incremental sync) | ✅ | #70 |
| 1.10 | Write path & ingest API | ✅ | #72 |
1.1 — Storage backends ✅ #40
rag-backends:PgVectorStore(asyncpg + pgvector, ivfflat),QdrantVectorStore(query_points),RedisCache,S3Storage(aioboto3, MinIO-compatible),LocalFileStorage- Integration tests (skip-if-no-service); MinIO added to dev stack;
task test-integration+test-backends; ADR-0004
1.1a — Core type & SPI refactor ✅ #44
RequestContextfrozen model threaded through every SPI (always first arg)- Typed
tenant_id+acl_labelson Chunk/Embedding (not metadata dict);trust_levelon Chunk (injection defense);dtypeon Embedding BlobRef(lazy chunk text),QueryPlan,ChunkRef,Cost,PlanNode, typedStageEventspi_signature.pylinter (ctx-first); backends migrated; ADR-0005 / 0007 / 0008 / 0009
1.1b — SPI split — Retrieval/Index, bulk + streaming + ID-only ✅ #45
- Split Vector/Keyword/Graph into
*RetrievalBackend(read) +*IndexBackend(write) composites retrieve_ids→list[ChunkRef];hydrateon the retrieval side- Bulk
bulk_index/bulk_delete(+ graph variants);stream_indexasync-iterator default;Embeddersplit singleembed+bulk_embed IndexHint+WriteVolumepassed to writes (ADR-0009)
1.1c — PolicyEngine package ✅ #46
rag-policyv0.1.0:PolicyEngineSPI +NoopPolicyEngine(always-ALLOW + tenant-scopedfilter_pushdown)PolicyDecisionenum (read_chunk / ingest_doc / egress_text / quota_check / rate_limit / execute_plan);PolicyResult(allow/deny/transform)FilterExprmini-language (Eq / AnyIn / And / Or / Not / TrueExpr);PolicyWriterfacade →policy.decisionlogs- Coverage linter
tests/policy/coverage.py(greps governance SPI calls w/o PDP consultation); 20 tests; ADR-0005 finalized
1.1d — Pipeline + Batcher primitives ✅ #50
Pipeline(rag_core.pipeline) — async DAG, bounded queues, per-stage workers, backpressure,on_errorfail/skipBatcher[Req, Resp](rag_core.batcher) — DataLoader coalescing, size + time triggers, per-request Future isolation,flush()- PEP-695 generics; 22 unit tests
1.1e — Cache SPI split + perf discipline + async telemetry ✅ #51
EmbeddingCache/RetrievalCache/AnswerCacheSPIs (ctx-first, version-partitioned, invalidation hooks) + noop impls (18 tests)AsyncTelemetrySink(rag-observability) — bounded buffer, non-blockingsubmit(), drop + error counters (9 tests)- Hot-path discipline doc in
performance.md(hot/cold call-site table)
1.1f — ADRs 0005–0009 + reviewer checklist ✅ #52
- ADR-0005 (PolicyEngine PDP), 0006 (two-stage reranker), 0007 (tiered storage + BlobRef), 0008 (cost-aware planner), 0009 (vector index + quantization) → Accepted
- Reviewer checklist extended; module-level ADR backlinks on
reranker+storage
1.2 — Connectors framework ✅ #55
ConnectorSPI →(Document, ConnectorState)async-iterator with resumable watermark- Internal
Crawler[Raw]base; built-inFilesystemConnector,S3Connector,GCSConnector([gcs]extra) - Connectors upstream of PolicyEngine (ingest pipeline is the enforcement point); 18 tests
1.3 — Document parsers ✅ #57
rag-parsers: 10 parsers — text, markdown, HTML, JSON, CSV, YAML, PDF, DOCX, PPTX, XLSXParserSPI →ParsedDocument(text + structuralBlocklist +BlockType);detect_mime();ParserRegistryragctl parse; 148 contract tests total
1.4 — OCR pipeline ✅ #60
rag-ocr:TesseractOCR(per-word) +PaddleOCRBackend(per-line) behind[tesseract]/[paddle]extrasBoundingBox/OCRRegion/OCRResultfrozen models (length-weighted confidence)ragctl ocr; 27 tests (engines stubbed atsys.modules)
1.5 — Structure-aware chunker ✅ #62
rag-chunker:HeadingAwareChunker— heading-level stack forparent_id, token budget (512/64 overlap), sentence-boundary splitting, never bleeds across headingsChunkerSPI +NoopChunker;TokenCounterProtocol +TiktokenCounter; OCR→ParsedDocument adapterragctl chunk; 40 tests
1.6 — Metadata enricher ✅ #64
rag-enricher:DefaultEnrichertags language, doc_type, created/modified, author, title, section_path, reading_level (Flesch-Kincaid)EnricherSPI;LanguageDetectorProtocol + seededLangdetectDetectorragctl enrich; 34 tests
1.7 — PII detection ✅ #66
rag-pii:RegexPIIDetector(EMAIL/PHONE/SSN/CREDIT_CARD/IP, Luhn-validated; shares definition with the log-leak gate) +PresidioPIIDetector([presidio])PiiProcessor— redact (default) / mask / block;pii.detectedevent carries metadata only (never raw text)PIISpanpromoted to frozen model;ragctl pii; 48 tests
1.8 — Embedder pipeline ✅ #68
rag-embedders:OpenAIEmbedder,CohereEmbedder,SentenceTransformersEmbedder(BGE + E5) — 4 model families behindEmbedderSPIBatchingEmbeddershared sub-batch split + retry (RetryPolicy) + dimension normalizeragctl embed(noop default, no creds); 46 tests
1.9 — CDC connectors (incremental sync) ✅ #70
PostgresCDCConnector(pgoutput WAL LSN cursor, tombstones),S3EventsConnector(SQS long-poll, SNS-wrapped),WebhookReceiverConnector(push, backpressure modes)DedupFilter(wraps any Connector + Cache, drops byDocument.fingerprint);DedupStats- 35 tests (SDKs stubbed)
1.10 — Write path & ingest API ✅ #72
- 🥈 CLI demo milestone — Phase 1 complete.
rag-ingest:IngestPipeline— parse → chunk → enrich → PII → embed → index (PII before embed so vectors never encode PII);PolicyEngine.evaluate(ingest_doc)short-circuit; per-doc error isolationPOST /v1/ingest/document(multipart) +build_app(pipeline=)DI;ragctl ingestwired for real- IngestPipeline is the canonical
ingest_docPDP consumer; 38 tests
| Step | Title | Status | PR |
|---|---|---|---|
| 2.1 | Knowledge store read layer | ✅ | #74 |
| 2.2 | Vector retrieval backends | ✅ | #76 |
| 2.3 | BM25 / keyword retrieval | ✅ | #82 |
| 2.4 | Graph retrieval | ✅ | #84 |
| 2.5 | Hybrid RRF fusion | ✅ | #86 |
| 2.6 | Query understanding | ✅ | #90 |
| 2.7 | Cross-encoder reranker | ✅ | #92 |
| 2.8 | Context packer | ✅ | #94 |
| 2.9 | GraphRAG | ✅ | #96 |
| 2.10 | Retrieval router | ✅ | #98 |
| 2.11 | Agent-loop validation spike | ✅ | #100 |
2.1 — Knowledge store read layer ✅ #74
FilterExprAST + in-memoryevaluate()relocated torag_core.filterso SPI signatures take it directly (rag_policy.filterre-exports for back-compat)retrieve_idsswitched to typedFilterExpr | Noneon Vector + Keyword backends; new signature gate- Noop stores apply the predicate via
evaluate()(conformance oracle); pgvector_filter_sql.translate()+acl_labels TEXT[]GIN column; Qdrant_filter_qdrant.translate(); unsupported fields raise (no silent push-down loss) - 75 tests; rag-core 0.13 → 0.14; architecture/retrieval-read-layer.md
2.2 — Vector retrieval backends ✅ #76
WeaviateVectorStore([weaviate]),PineconeVectorStore([pinecone]),ElasticsearchDenseVectorStore([elasticsearch]) — each with its ownFilterExprtranslator + tenant isolation +acl_labelspush-down- All five vector backends consume
IndexHintvia sharedselect_index_variant()(ADR-0009 size/recall → ivfflat / HNSW / PQ) - Lazy SDK imports; 53 tests; reference/backends.md, architecture/vector-backends.md
2.3 — BM25 / keyword retrieval ✅ #82
ElasticsearchKeywordStore([elasticsearch]) +TantivyKeywordStore([tantivy]) on the Step 2.1 read-layer contract (no new SPI)- Per-field boosting is a backend config knob (
field_boosts), not a per-call param; identical indexed-field schema across both (swap = config change) _filter_es/_filter_tantivytranslators (tantivyNotpaired withall_query); 47 tests; architecture/keyword-backends.md
2.4 — Graph retrieval ✅ #84
Neo4jGraphStore([neo4j]),MemgraphGraphStore([memgraph]),NetworkXGraphStore([networkx]); Neo4j + Memgraph shareBoltCypherStore- New
expand(ctx, seed_ids, hops, rel_types, direction, node/edge_filter, limit) → list[NeighborResult](non-abstract default;NoopGraphStoreBFS is the oracle) FilterExprpush-down on edges (every hop — the ACL case) + final node;_filter_cypher.translate()withparam_prefix; rel-type injection guard; tenant isolation on every edge- 65+ tests;
NeighborResultschema; architecture/graph-backends.md
2.5 — Hybrid RRF fusion ✅ #86
- New
rag-retrievalpackage. Purerrf_fuse(rankings, weights, k=60, top_k)— rank-onlyΣ w/(k+rank), earliest-source metadata, cross-tenant raise HybridRetriever— parallel vector + keyword + graph fan-out (asyncio.gather(return_exceptions=True)), per-call source skip, partial-failure tolerance (only all-fail raises)- Canonical
read_chunkPDP call site viapolicy_engine.filter_pushdownAnd-merged with the caller filter;HybridWeights;GraphAdapterprotocol ragctl hybrid; 43 tests; reference/retrieval.md, architecture/hybrid-fusion.md
2.6 — Query understanding ✅ #90
- New
rag-querypackage — four components + a parallel orchestrator LLMQueryRewriter;LLM/Heuristic/NoopQueryDecomposer(conjunction + sentence splits,[]sentinel);HyDEGenerator(Gao 2022, optional embedder);InMemoryGlossary+ expanderUnderstoodQueryfrozen output (rewrites/sub_queries/expansion_terms/hyde_*);QueryUnderstandingPipelinefan-out, per-component degrade to identityquery.understandspan;ragctl understand; 60 tests; reference/query.md, architecture/query-understanding.md
2.7 — Cross-encoder reranker ✅ #92
- New
rag-rerankerpackage — ADR-0006 two-stage cascade.RerankerSPI →fast_rerank/precise_rerank/should_early_exit;Chunk.scoreadded (rag-core 0.14 → 0.15) - Pure
mmr_rerank()(Carbonell-Goldstein, cosine cache, stable at λ=0);RerankPipeline— fast → early-exit? → hydrate → precise → optional MMR; strict failure (RerankerError(stage=)) - Backends:
SentenceTransformersCrossEncoder(BGE v2-m3 / MiniLM),CohereReranker,JinaReranker(lazy extras) rerankspan;ragctl rerank; 92 tests; architecture/reranking.md, reference/reranker.md
2.8 — Context packer ✅ #94
- New
rag-packerpackage.ContextPacker— tenant check → sort by score → hash dedup → opt-in Jaccard dedup → greedy token-budget fit (TokenCounter/TiktokenCounter,Chunk.token_countfast-path) → reorder (lost_in_middledefault, Liu 2024) → heuristic conflict scan (trust-mismatch + numeric-disagreement) PackedContext+ConflictAnnotationfrozen models (rag-core 0.15 → 0.16); strictPackerError(budget ≤ 0 / mixed-tenant /content_refw/otoken_count); conflict scan is the one degrade-and-log pathpackspan;ragctl pack; 78 tests
2.9 — GraphRAG ✅ #96
- New
rag-graphragpackage:LouvainDetector(default) +LeidenDetector([leiden]) community detection overGraphRetrievalBackend(no new SPI) CommunitySummarizer(LLM strict-JSON, NoopLLM fallback),InMemoryCommunityStore(tenant-scoped),graphrag_adapter(entity-node →ChunkRef)GraphRAGRetriever— set-overlap community scoring (2× onkey_entities) → seed collection →expand()fan-out → returnsChunkRefforHybridRetrieverCommunity/CommunitySummary(rag-core 0.16 → 0.17); 3graphrag.*spans;ragctl graphrag; 54 tests; reference/graphrag.md, architecture/graphrag.md
2.10 — Retrieval router ✅ #98
RetrievalRouter+ pureclassify_shape(KEYWORD_HEAVY / SEMANTIC / ENTITY_RICH / MIXED) layered onHybridRetriever(rag-retrieval 0.1 → 0.2)- In-memory
BackendHealthTracker(rolling-window, injected clock); shape-bias weight table; vector-input priority (caller > HyDE > embed > skip) - BM25-only fallback when vector + graph are down; failure-feedback loop (
record_failure/record_success) converges degradation without an external loop RoutingDecision+QueryShape(rag-core 0.17 → 0.18);retrieve.routespan;ragctl route; 42 tests; reference/router.md, architecture/retrieval-router.md
2.11 — Agent-loop validation spike ✅ #100
AgentLoopV0(rag-retrieval0.2 → 0.3) — thin iterative-retrieve orchestrator: per-iter budget enforcement, sticky routing, plan-reuse measurement, sharedEmbeddingCache, sub-query fan-out; cross-package isolation viaQueryUnderstander/UnderstoodQueryLikeProtocolsStopReason/BudgetSpend/CacheStats/AgentLoopError(rag-core 0.18 → 0.19);agent_loop.runspan- 50-query harness in
eval/agent_loop_v0/(5 buckets). Headline finding: 0% embedding-cache hit on the decomposable bucket → drove the G-01 must-fix ragctl agent-loop; 66 tests; reference/agent-loop.md, spikes/agent-loop-v0-gaps.md- Pre-3.1 must-fix window (#101–#105): G-02 split route → decide + execute · G-03 reject zero budgets · G-06 cache HyDE + rewriter · G-01
SemanticEmbeddingCache(hit rate 28% → 54%, ADR-0010) · G-04 per-stageCostEstimator(ADR-0008)
| Step | Title | Status | PR |
|---|---|---|---|
| 3.1 | Gateway service (REST + OpenAPI) | ✅ | #107 |
| 3.2 | gRPC service | ✅ | #109 |
| 3.3 | MCP server | ✅ | #111 |
| 3.4 | OpenAI-compatible endpoints | ✅ | #113 |
| 3.5 | Corpus router | ✅ | #115 |
| 3.6 | Agent loop | ✅ | #120 |
| 3.7 | Official SDKs | ✅ | #121 |
| 3.8 | Framework adapters | ✅ | #122 |
| 3.9 | Webhooks | ✅ | #123 |
| 3.10 | Admin UI v0 | ✅ | #124 |
| 3.11 | Status & Metrics GUI (MVP) | ✅ | #125 |
3.1 — Gateway service (REST + OpenAPI) ✅ #107
- 🥈 Curl-able RAG milestone.
rag-gateway0.3 → 0.4:POST /v1/query(full RAG →QueryResponse),POST /v1/retrieve,GET /v1/corpora+/{id}(newCorpusStoreSPI +NoopCorpusStore) rag-core0.19 → 0.20 wire types inrag_core.gateway_types(Corpus,QueryRequest/Response,RetrieveRequest/Response,Answer,StageTimings,GatewayError)- Middleware builds per-request
RequestContext(X-Request-Id / traceparent / Bearer); domain errors → HTTP (403 / 401 / 429 / 502 / 400) viaGatewayErrorenvelope;PolicyEngine.egress_textbefore every LLM call build_app(...)injectable factory (noop defaults, zero infra); OpenAPI 3.1 + Swagger / ReDoc;ragctl query; 43 tests; reference/gateway.md, guides/curl-quickstart.md
3.2 — gRPC service ✅ #109
proto/rag.proto—rag.gateway.v1.RagService: server-streamingQuery(QueryStreamEventoneof) + unaryRetrieve/IngestDocument/ListCorpora/GetCorpus; health + reflection co-registered (grpcurl works)- Python stubs via
grpcio-tools+mypy-protobuf(committed, drift-gated);task proto:lint/gen/check-drifton full OS matrix GrpcRagServicereuses the same Phase 2 composition as REST (build_grpc_server(...)); error → StatusCode mapping mirrors REST;GatewayErrorpacked on trailing metadataragctl grpc-query; 48 tests; ADR-0011, reference/grpc.md
3.3 — MCP server ✅ #111
- New
rag_gateway.mcp—FastMCPover the officialmcpSDK (stdio); three toolsquery/retrieve/ingest ToolBackendProtocol is the topology seam:InProcessBackendshares one process wiring with REST + gRPC; a futureGatewayClientBackendslots in (ADR-0012)@ragplatform/mcpnpm launcher (pure-Nodebin→python -m rag_gateway.mcp, cross-platform interpreter discovery, no injection surface)ragctl mcp-query/mcp-serve; 21 tests; ADR-0012, reference/mcp.md
3.4 — OpenAI-compatible endpoints ✅ #113
rag_gateway.openai_compat:POST /v1/embeddings,POST /v1/chat/completions,GET /v1/models— drop-in for OpenAI SDKs / LangChain / LlamaIndex- Retrieval pre-fetch: chat runs the Phase 2 pipeline over the last user turn, egress-policed, injected as a system message; on by default (
rag.enabled);RagOptionsrequest field +RagAnnotationsresponse field stream=true→ OpenAI-faithfulchat.completion.chunkSSE; base64 embeddings; header identity w/ NoopAuth dev-anon fallback; OpenAI{error:{...}}enveloperag-core0.20 → 0.21openai_types;ragctl chat/embeddings; 36 tests; ADR-0013, guides/openai-quickstart.md
3.5 — Corpus router ✅ #115
CorpusRoutersits above the Step 2.10RetrievalRouter— it answers which corpora a query touches.decide()precedence: explicit pin → static rules → learned classifier → all (auto-detects effective strategy);UNCONSTRAINEDpreserves pre-3.5 behaviourroute()fan-out (default): oneRetrievalRouter.routeper corpus,rrf_fuseweighted by corpus score;StaticRuleClassifier(auditable) + dependency-freeLearnedCorpusClassifier(softmax overterm_weights) behindCorpusClassifierProtocolCorpusRoutingStrategy/CorpusScore/CorpusRoutingDecision(rag-core);corpus_decisionon Query/Retrieve responses; PostgresPgCorpusStore(lazy); config-drivenbuild_app_from_configin gateway wiring (keeps theconfig → coreboundary)corpus.routespan + audit event;ragctl corpus list/seed/route; 35 tests; ADR-0014, reference/corpus-router.md
3.6 — Agent loop ✅ #120
- New
rag-agentpackage (rag-core only) graduates the 2.11 spike.AgentLoopis an explicit state machine (planning → acting → observing/finalizing) driven by a pluggableController;stream/run/resume - Four nullable budget dims spent per step (stop checked top-of-step);
ScriptedController/HeuristicController(creds-free) /LLMController;RetrieveTool(recoverableRetrievalError); resumable via frozenAgentSnapshot+CheckpointStore rag_core.agent_types; surfaces: RESTPOST /v1/agent(SSE) + gRPCRagService/Converse(streaming) sharing one flat event frame + egress helpers; final-answer egress-policed once at the boundaryagent.run/grpc.conversespans;ragctl agent; full unit + gRPC + proto-compat suites; ADR-0015, reference/agent.md
3.7 — Official SDKs ✅ #121
- Committed, drift-gated OpenAPI 3.1 (
dist/openapi.{json,yaml}viaexport_openapi.py;openapi-driftCI job) - Hand-written flagships: Python
agentcontextos(sync + async, SSE, reusesrag-coremodels; 32 tests vs a live gateway) + TypeScript@agentcontextos/sdk(zero-dep fetch / ReadableStream, Node + browser; 10 vitest) - Generated on demand: Go / Java / .NET via
task sdk:gen(openapi-generator +buf generate); generated source gitignored, configs committed ts-sdkCI job; gateway 0.5 → 0.7; ADR-0016, reference/sdks.md
3.8 — Framework adapters ✅ #122
agentcontextos.integrations(insdks/python) — 8 integrations wrapping the SDKClient: LangChain, LlamaIndex, Haystack, DSPy, LangGraph, CrewAI, AutoGen, Semantic Kernel- Shared
_common.pyruns/v1/query(pack / generate off) → mapsChunkto framework docs / tool strings; each lazy-imports its framework behind an optional extra with a friendlymissing_dependency - 13 tests vs real framework base classes;
integrationsCI job; ADR-0017, reference/integrations.md
3.9 — Webhooks ✅ #123
- New
rag-webhookspackage (rag-core + httpx):WebhookDispatcher(tenant fan-out + HMAC signing + backoff retries + span / log),HttpWebhookSender,signing.py(HMAC-SHA256 over<ts>.<body>,verify()with replay tolerance) rag-core0.23 → 0.24webhook_types+SubscriptionStore/WebhookSenderSPIs + noops; emission via theWebhookPublisherseam (no emitter depends on rag-webhooks); ingestingest.completed,PolicyWriteraudit.policy_violation;drift.detected/eval.regressiontypes reserved for Phase 5- Gateway
make_webhooks_router()(CRUD +/test), secret returned once then masked;WebhooksConfigin rag.yaml;ragctl webhooks demo; ~50 tests; ADR-0018, reference/webhooks.md
3.10 — Admin UI v0 ✅ #124
- Next.js 14 (App Router) + TS + Tailwind operator console (
apps/admin-ui) to a committed design handoff; hand-rolled shadcn-style primitives (Radix-free, no CLI) on shadcn CSS-var tokens + Geist + lucide — deterministic offline builds - App shell (collapsible sidebar, tenant switcher, theme toggle) + 9 pages: Dashboard, Corpora, Connectors, Glossary, Webhooks (one-time secret reveal + send-test), Audit Log (filters + pagination), API Keys (Preview), Tenants (Preview), Config Viewer
- Live-vs-seed hybrid (
lib/use-live.ts): Corpora + Webhooks hit the gateway whenNEXT_PUBLIC_GATEWAY_URLis set, else seed data + a "Demo data" badge - 14 vitest + RTL; tsc / lint / build clean;
admin-uiCI job; ADR-0019, reference/admin-ui.md
3.11 — Status & Metrics GUI (MVP) ✅ #125
rag-observabilityread-side:MetricsCollector(counter / gauge / histogram, reservoir p50/p95/p99,snapshot()) +LogTail+RingBufferLogHandler(poll byseqcursor)rag_gateway.status(make_status_router): REST/v1/status/health(per-component roll-up from real error rates) /metrics/logs/connectors/status; SSE logs stream; WebSocket health + metrics push (ADR-0020)- Request-timing middleware feeds the metrics (status surface excluded); CORS for the console;
build_appinjectables; gateway 0.7 → 0.8 - Admin Observability nav: Live Status (WS) / Metrics (WS) / Logs (SSE) + Dashboard banner; 45 tests; ADR-0020, reference/status-api.md · full 12-page GUI is Step 5.6
| Step | Title | Status | PR |
|---|---|---|---|
| 4.1 | Semantic cache (L0/L1/L2) | ✅ | #126 |
| 4.2 | Fallback chain | ✅ | #127 |
| 4.3 | Hallucination guard | ✅ | #128 |
| 4.4 | Circuit breakers | ✅ | #129 |
| 4.5 | Quotas & rate limiting | ✅ | #130 |
| 4.6 | Latency tuning & load test | ✅ | #131 |
4.1 — Semantic cache (L0/L1/L2) ✅ #126
- New
rag-cachepackage:TtlLruCache+ L0 (InMemory{Retrieval,Answer,Embedding}Cache), L2 similarity (Semantic{Retrieval,Answer}Cache+QuerySimilarityIndex),Tiered{Retrieval,Answer}Cache(read-through + promotion, write-through,cache.*events +TierStats) - L1
Redis{Retrieval,Answer}Cacheinrag-backends(tag-set invalidation), injected; reliability: L1 failures degrade, never raise - Gateway
/v1/{query,retrieve}consult the cache (served_from_cache);CacheConfigtiered knobs; cross-tenant red-team suite - ADR-0021, reference/cache.md
4.2 — Fallback chain ✅ #127
rag_retrieval.fallback.FallbackChainwrappingRetrievalRouter— degradation ladder hybrid → BM25-only → keyword → "no answer"- Triggers: proactive budget (
CostEstimatorstart-tier, ADR-0008) + reactiveRetrievalError/ empty-below-min_results; gracefulno_answer(HTTP 200,no_answer_on_exhaustiontoggle); keyword rung relaxes caller filters only (ACL pushdown preserved) SupportsRoutedrop-in (CorpusRouter relaxed) +build_appdefault wrap;FallbackTier/FallbackTrigger/FallbackResult;retrieve.fallbackspan +fallback.engagedeventragctl fallback; 39 tests; architecture/fallback-chain.md, reference/fallback.md
4.3 — Hallucination guard ✅ #128
- New core-only
rag-guardpackage:HallucinationGuard(claim split → per-claim NLI vs. evidence → per-tenant threshold → annotate / redact / block);NLIScorerSPI (mirrorsReranker) +NoopNLIScorer+ dependency-freeLexicalNLIScorerdefault - Frozen types
NLILabel/NLIScore/GuardVerdict/GuardAction/ClaimVerdict/GuardResult+GuardError; runs afteregress_texton/v1/query+ OpenAI chat (sync block ⇒content_filter; stream advisory) + MCPquery;Answer.guard+RagAnnotations.guard - Disabled by default; reliability degrade-never-raise (
guard.scorer_degraded);guard.checkspan + PII-safeguard.claim_blockedevent cfg.guard;ragctl guard; ADR-0022, reference/guard.md
4.4 — Circuit breakers ✅ #129
- New core-only
rag-breakerpackage:CircuitBreaker(3-state closed / open / half-open, consecutive-failure trip + timed half-open probe, injected clock),BreakerRegistry(per-backend, force-close) Breaker{Vector,Keyword,Graph}RetrievalBackendSPI wrappers — an open breaker raisesCircuitOpenError, whichHybridRetriever'sgather(return_exceptions=True)already drops (zero fan-out changes); generalises the 2.10 health trackerCircuitState/BreakerSnapshot+CircuitOpenError; PII-freebreaker.openedevent; no per-call span; on by default- Gateway
GET /v1/status/breakers+POST .../{name}/force-close+ health roll-up; admin-ui Live Status breakers card;cfg.breakers;ragctl breaker; ADR-0023, reference/breaker.md
4.5 — Quotas & rate limiting ✅ #130
- New
rag-quotapackage enforcing per-tenant quotas through the PolicyEngine PDP (ADR-0005):QuotaPolicyEngine(answersrate_limit/quota_check) over aQuotaEnforcer(5 dims — QPS + monthly tokens / cost / queries + storage gauge; micro-dollar cost; snapshot / reset) QuotaStoreSPI — weighted two-slot sliding-windowInMemoryQuotaStore+ atomic-LuaRedisQuotaStore(rag-backends, injected) +NoopQuotaStore;QuotaVerdict/QuotaSnapshot/QuotaDimension+QuotaExceededError(RateLimitError)- Wired on every surface via
build_app(quota_enforcer=)+quota_guard(REST query / retrieve, OpenAI chat + embeddings, ingest storage); 429 +Retry-Afterpre-check, post-response metering. Disabled by default (can reject); fail-open by default (quota.store_degraded) GET /v1/status/quotas+ reset + health component; admin-ui Quotas card;ragctl quota; ~82 tests + 2 vitest; ADR-0024, reference/quota.md
4.6 — Latency tuning & load test ✅ #131
- Gateway overhead p99 ≤ 30 ms gate (default noop backends → per-request wall time is the gateway's own cost; distinct from the backend-bound end-to-end target); gate signal is the server-side
gateway.request_duration_msfrom the 3.11MetricsCollector - Driven in-process via httpx
ASGITransport, sequentially, retry-tolerant; dedicated single-runnerperf-gateCI job (excluded from the cross-OS sweep via-m "not perf") - One reusable engine
rag_gateway.perf(measure_gateway_overhead+profile_gateway) powers gate + harness +ragctl perf;tests/contract/budgets.pymade real (drift-guarded); load test = in-processeval/gateway_load_v0+ Locustlocustfile.py - ~14 tests; ADR-0025, reference/perf.md, architecture/latency.md
| Step | Title | Status | PR |
|---|---|---|---|
| 5.1 | Per-query tracing & provenance | ✅ | #132 |
| 5.2 | Offline eval harness (golden set) | ✅ | #133 |
| 5.3 | CI eval gate | ✅ | #134 |
| 5.4 | Online metrics & feedback | ✅ | #135 |
| 5.5 | Drift monitors | ✅ | #136 |
| 5.6 | Status & Metrics GUI (full build) | ✅ | #137–#142 |
| 5.6a | — Drift & Feedback console cards | ✅ | #137 |
| 5.6b | — Query Trace viewer | ✅ | #138 |
| 5.6c | — Cost anomaly | ✅ | #139 |
| 5.6d | — Regression bisector | ✅ | #140 |
| 5.6e | — Grafana dashboards | ✅ | #141 |
| 5.6f | — Cross-links + close-out | ✅ | #142 |
| 5.7 | A/B testing & shadow mode | ✅ | #143–#147 |
| 5.7a | — A/B analyzer + tracker + dashboard | ✅ | #143 |
| 5.7b | — Shadow mode | ✅ | #145 |
| 5.7c | — A/B routing | ✅ | #146 |
| 5.7d | — Console + close-out | ✅ | #147 |
5.1 — Per-query tracing & provenance ✅ #132
- Stable span attributes:
rag.schema_versionstamped at thespan_from_trace_contextchoke point + atelemetry_attrsregistry/contract - HMAC-signed
ProvenanceRecord— newrag-provenancepackage, privacy-by-default hashes, degrade-open GET /v1/query/{id}/trace— signed record +TraceCollector-captured span tree, tenant-scoped- ADR-0026, reference/provenance.md, architecture/per-query-tracing.md
5.2 — Offline eval harness (golden set) ✅ #133
- 500 queries / 5 domains (committed
tests/eval/golden/, drift-gated) run through the realHybridRetrieverover a synthetic corpus (noop SPIs +HashingEmbedder, deterministic) - Metrics: Recall@k / MRR / nDCG@k / Faithfulness (dependency-free
lexical_faithfulness, RAGAS optional) / Citation Precision — overall + per-domain + per-difficulty render_html_reportHTML +report.json/report.md;eval/golden_set_v0/harness +--checkthreshold floor;ragctl eval run --html- ADR-0027, reference/eval-harness.md, architecture/golden-set-eval.md
5.3 — CI eval gate ✅ #134
- Standalone always-run
eval-gate.ymlruns the harness, compares vs committedtests/eval/baselines/main.json(compact byte-stableEvalBaseline) - Enforces
thresholds.yamlfloors + regression deltas (recall@10 / mrr / faithfulness gate; acl / pii are the red-team suites'); sticky-marker PR comment with a diff table (even on failure, fork-PR tolerant) - Comparison in
rag_config.eval(compare_to_baseline/render_gate_comment); result types inrag_core.eval; runnerpython -m eval.golden_set_v0.gate - ADR-0028, architecture/ci-eval-gate.md
5.4 — Online metrics & feedback ✅ #135
- New
rag-feedbackpackage:FeedbackRecorder(normalise signal →[-1,1]score → PII-redact comment via an injectedPIIDetector→ tenant-scopedFeedbackStore.put→feedback.recorded; degrade-open) + pureaggregate_feedback→FeedbackStats - One polymorphic
POST /v1/feedback(explicit + implicitFeedbackSignal, body identity, redact-don't-hash, acksstored=falsewhen inert / degraded) + per-tenantGET /v1/status/feedbackdashboard FeedbackRecord/FeedbackStats+FeedbackRequest/FeedbackAck;cfg.feedback;ragctl feedback- ADR-0029, reference/feedback.md, architecture/online-feedback.md
5.5 — Drift monitors ✅ #136
- New
rag-driftpackage:population_stability_index(pure binned PSI) +DriftMonitor(ref / current windows, PSI or mean-drop, tri-state verdict, rebaseline) +DriftMonitorRegistry(five monitors) - Monitors: query distribution / embedding PSI / retrieval score (via PSI) + citation-clickthrough / faithfulness (via mean-drop); infra-scoped, fed via
observefrom the query + feedback paths evaluateemitsdrift.detected(event + Step 3.9 webhook) on the transition into drift;DriftSnapshot/DriftReport;cfg.drift;GET /v1/status/drift+POST .../{metric}/rebaseline;ragctl drift- ADR-0030, reference/drift.md, architecture/drift-monitors.md
5.6 — Status & Metrics GUI (full build) ✅ #137–#142
- Delivered in six vertical slices (5.6a–f), detailed below: Drift / Feedback + Cost cards on Live Status, the Query Trace viewer page, the regression bisector, drift + cost Grafana dashboards, observability cross-links
- Deferred (documented): feedback / breaker / quota Grafana export, a Loki-events dashboard, per-domain eval gating
5.6a — Drift & Feedback console cards ✅ #137
- Surface
GET /v1/status/drift(per-monitor verdict + one-click rebaseline) andGET /v1/status/feedback(mean score, sentiment split, signal counts) as two Live Status cards (useLivelive-vs-seed) - New TS types +
fetchDrift/rebaselineDrift/fetchFeedback+ seeds; 3 vitest; browser-verified
5.6b — Query Trace viewer ✅ #138
- New Query Trace page (
/trace): paste arequest_id→GET /v1/query/{id}/tracerenders the signed provenance record, an HMAC verification pill, evidence citations, and the captured span tree as an offset / duration waterfall - Input-driven fetch + seed fallback + 404 path;
QueryTrace/SpanRecord/Provenance*types +fetchTrace; 3 vitest
5.6c — Cost anomaly ✅ #139
- New
CostTracker(rag-observability) — bounded per-tenant rolling window of recent per-request token costs; two-gate (ratio + z-score) tri-state verdict; scale-free on tokens (decoupled from quota pricing); fed O(1) fromrecord_request_usage, independent of quotas - Pull-based
GET /v1/status/cost(no per-request span / event);CostSnapshotadataclass→ norag-core/dist/schemaschurn; admin Cost-anomaly card; 16 tests + 1 vitest - ADR-0031, reference/cost.md, architecture/cost-anomaly.md
5.6d — Regression bisector ✅ #140
python -m eval.golden_set_v0.bisect --good <ref> --bad HEAD --metric <m> --threshold <floor>— binary-searchgood..bad(O(log n) harness runs) for the first commit that dropped a metric- Each candidate runs in a throwaway
git worktreewith that commit'spackages/*/srconPYTHONPATH; purebisect_commits→BisectResult/BisectStep(additive); 7 unit tests + live worktree smoke - reference/eval-harness.md
5.6e — Grafana dashboards ✅ #141
rag_observability.register_platform_metricsregisters OTel observable gauges —rag.drift.{statistic,threshold,drifted}(fromreport()) +rag.cost.{ratio,elevated,recent_mean_tokens}(fromCostTracker.snapshot_all) — on the existing OTel → collector → Prometheus pipeline; registered once bybuild_app_from_config- Provisioned
rag-quality-costGrafana dashboard; proven by an OTelInMemoryMetricReadertest + a dashboard-validation gate (tests/infra/test_grafana_dashboards.py) - guides/grafana-dashboards.md
5.6f — Cross-links + close-out ✅ #142
- Wires the observability surfaces together: the Trace viewer's Logs button →
/logs?trace=<id>(clearable violet trace-chip), a log detail drawer → View this trace's logs, a Trace quick-link on Live Status - Admin-ui only; 1 vitest cross-link spec; closes Step 5.6
- Final Phase-5 step, delivered in vertical slices 5.7a–d: analyzer + tracker + dashboard (5.7a ✅), shadow mode (5.7b ✅), A/B routing (5.7c ✅), console + close-out (5.7d ✅)
- Shadow N% of live queries; A/B variant routing; statistical analyzer with lift + confidence interval — all delivered; Phase 5 complete
5.7a — A/B analyzer + tracker + dashboard ✅ #143
- Pure
analyze_ab_experiment(rag_config.eval) — lift + normal-approx Welch CI + significance via stdlibstatistics.NormalDist(no numpy / scipy) →ABAnalysisResult(rag_core.eval, additive, not indist/schemas) ABExperimentTracker(rag-observability, besideCostTracker) — a pure per-(experiment, variant)sample holder, decoupled from the analyzer (observability keeps norag_configdep); the gateway composes the two atGET /v1/status/experimentscfg.experiments(opt-in, since A/B routing can change responses); wired inert by default; observe-only — no query-path change yet (shadow / routing are 5.7b/c);ragctl experiments; 17 tests- ADR-0032, reference/experiments.md
5.7b — Shadow mode ✅ #145
- New
ShadowRunner(rag_gateway.experiments) runs a candidate retriever alongside the served control path on a sampled fraction of live queries — observe-only — and feeds both variants'outcome_metric(mean retrieval score) into the 5.7aABExperimentTracker - Never delays the response: scheduled as a FastAPI
BackgroundTask(runs after the response is sent); degrade-open (experiment.shadow_failed); skipped on a retrieval-cache hit - Deterministic
request_id-hash sampling; candidate = anySupportsRoute(config builds aRetrievalRouterdiffering only inshadow_candidateRRF weights; production injects one viabuild_app(shadow_runner=…)); sameread_chunkPDP — no coverage-linter entry cfg.experimentsgainsshadow_enabled/shadow_sample_rate/shadow_experiment/shadow_candidate; doubly opt-in (enabledandshadow_enabled);ragctl shadow; 27 tests- ADR-0032, reference/experiments.md, architecture/ab-shadow-mode.md
5.7c — A/B routing ✅ #146
- New
ABRouter(rag_gateway.experiments, besideShadowRunner) — deterministichash(request_id) < routing_sample_ratevariant assignment that serves the candidate retrieval config to the assigned fraction of users (the first slice that can change a response);assign/is_candidate/candidate/record/tag - Served-path wiring in
/v1/query+/v1/retrieve: the candidate arm is routed inline via_route_query(one retrieval, no added latency); not degrade-open (a candidate failure → 502, never a silent fallback to control that would corrupt the experiment); the served arm'soutcome_metricis recorded once per fresh retrieval (skipped on a cache hit) - Variant-partitioned retrieval cache —
compute_plan_hash/compute_params_hashfold in the assignedvariant, so control and candidate never share a cache entry (byte-identical key when routing is off) - New frozen
ExperimentAssignment(experiment/variant/is_candidate) on theQueryResponse/RetrieveResponseexperimentfield (additive,dist/schemas+dist/openapiregenerated; REST-only — gRPC proto mirror deferred likecorpus_decision); served variant also on thegateway.query_completelog - Same
read_chunkPDP site (no coverage-linter entry); gated separately bycfg.experiments.routing_enabled(requiresenabled) — independent ofshadow_enabled— since routing can change a response;ShadowCandidateConfiggeneralised toCandidateConfig(shared byshadow_candidate+ newrouting_candidate);build_app(ab_router=…)inject seam;ragctl abdrives assign → serve → record → analyze; ~25 new tests; all gates green (ruff, mypy --strict, RAG001, schema/openapi-drift, proto-compat, policy-coverage) - ADR-0032 (5.7c update), reference/experiments.md, architecture/ab-routing.md
5.7d — Console + close-out ✅ #147
- New A/B experiments card on the admin Live Status page (
apps/admin-ui) surfacingGET /v1/status/experiments— one row per running experiment with the lift (relative, colour-coded), a verdict badge (candidate wins / candidate worse / no change / collecting), control→candidate means, the confidence interval, and per-arm sample counts; live-vs-seed viauseLive(fetchExperiments+seedExperiments), demo badge when no gateway ABAnalysisTS type mirrorsrag_core.eval.ABAnalysisResult; admin-ui gates green (tsc / eslint / 34 vitest /next build); browser-verified both the significant-win + insufficient-data states render- 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, reference/admin-ui.md
| Step | Title | Status | Planned deliverables |
|---|---|---|---|
| 6.1 | Logical multi-tenancy | ✅ | #148 — TenantResolver → TenantSettings (namespace / PII / ACL labels) applied at the boundary; RequestContext.namespace; GET /v1/status/tenant; ragctl tenant |
| 6.2 | Physical tenancy (dedicated index) | ✅ | #149 — dedicated_index → ctx.physical_index → backend <base>-<key> (Noop/Pinecone/Qdrant, lazy create); cross-tenant probe gate |
| 6.3 | ACL push-down at retrieval | ✅ | #150 — opt-in AclPolicyEngine And-merges any_in("acl_labels", …) into every read_chunk push-down (overlap, fail-closed); acl.egress_denied |
| 6.4 | ACL egress verifier | ✅ | #151 — AclEgressVerifier re-checks returned chunks at the gateway router boundary (defense-in-depth above the 6.3 push-down); acl.egress_violation; zero-violation-rate red-team gate |
| 6.5 | PII policies | ✅ | #152 — PiiPolicyEngine egress_text decorator (allow / redact / mask / block per tenant) over answer + context; reuses Step 1.7 detector; pii.egress_blocked |
| 6.6 | Immutable audit log | ✅ | 6.6a #153 — read API GET /v1/audit + GET /v1/audit/verify + shared store + cfg.audit. 6.6b #154 — AuditExporter signed WORM bundle + POST /v1/audit/export + ragctl audit |
| 6.7 | BYOK (Bring Your Own Key) | ✅ | 6.7a ✅ #155 — KeyManager SPI + envelope encryption (LocalKeyManager) + EncryptingStorage + sealing/isolation/tamper. 6.7b ✅ #156 — cfg.kms + per-tenant key + build_key_manager_from_config factory + AwsKmsKeyManager. 6.7c ✅ #157 — GcpKmsKeyManager / AzureKeyVaultKeyManager / VaultKeyManager behind [kms-*] extras. 6.7d ✅ #158 — RotatingKeyManager zero-downtime rotation + rewrap |
| 6.8 | SSO / SCIM | ✅ | #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 — 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 | ✅ | #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
- Makes per-tenant
rag.yamlconfig actually drive requests (it was a passive registry before; only quotas were per-tenant). NewTenantResolver(rag_config.tenancy) maps a tenant id → frozenTenantSettings(rag_core.types: namespace / pii_policy / acl_labels), merging thetenants:entry with safe defaults - Resolved once, at the boundary: the request-context middleware (
build_gateway_context) applies the resolved settings —namespace+pii_policyonto theRequestContext, the tenant'sacl_labelsunioned into the principal's; the resolved view is also exposed onrequest.state.gateway.tenant_settings - Unknown tenants resolve to safe defaults (namespace = the id, default PII
redact, no labels) — isolated, never privileged - New
RequestContext.namespace(defaults totenant_idvia a before-validator, so every pre-6.1 call site is unchanged); it's a backend-partition primitive — Pinecone now partitions onctx.namespace— not a chunk field, sofilter_pushdownis unchanged (chunks carrytenant_id) TenantConfiggainsnamespace+acl_labels(→rag.schema);TenantSettingsjoinsdist/schemas+RequestContextregenerated;GET /v1/status/tenant(→dist/openapi) surfaces the resolved view;ragctl tenant list/resolve <id>print it; inert by default inbuild_app, built fromcfg.tenantsbybuild_app_from_config- Scope: resolution + threading only — ACL push-down enforcement is 6.3, PII egress is 6.5, physical tenancy is 6.2; ~21 new tests (resolver, config, gateway, cross-tenant red-team, CLI); all gates green (ruff, mypy --strict 293 files, RAG001, schema/openapi-drift, proto-compat, policy-coverage)
- ADR-0033, reference/tenancy.md, architecture/multi-tenancy.md
6.2 — Physical tenancy (dedicated index) ✅ #149
- Logical tenancy (6.1) shares one index + a
tenant_idfilter; physical tenancy gives a tenant its own vector index/collection, so its data is separate even if a filter were bypassed. Opt-in per tenant viaTenantConfig.dedicated_index(+ optionaldedicated_index_name) - Same resolve-once-at-the-boundary pattern as 6.1: the resolver computes a
physical_indexkey (dedicated_index_name or tenant_id, elseNone) onTenantSettings, threaded onto newRequestContext.physical_index; the gateway middleware applies it - Backends read only
ctx(graph is backends→core, never rag-config), so the decision arrives viactx.physical_index; each namespaces its base under the key (<base>-<key>) and lazily creates the dedicated index/collection on first use, falling back to the base index whenNone. Implemented for Noop (CI oracle, partitioned byphysical_index) + Pinecone (dedicated index) + Qdrant (dedicated collection); pgvector/ES/Weaviate follow later - Cross-tenant probe gate (
tests/redteam/test_cross_tenant_dedicated_index.py, CI, Noop) proves a dedicated tenant's data is invisible to another independent of the tenant filter (a matchingtenant_idpointed at a different index still returns nothing); live backends are integration-tested - One instance + per-tenant derivation (no per-tenant backend instances, no SPI change);
GET /v1/status/tenant+ragctl tenant resolvereportdedicated_index/physical_index;TenantConfigfields →rag.schema,TenantSettings+RequestContextregenerated,/v1/status/tenant→dist/openapi; ~30 new/updated tests; all gates green - ADR-0034, reference/tenancy.md, architecture/multi-tenancy.md
6.3 — ACL push-down at retrieval ✅ #150
- Enforces label-based ACLs at retrieval: 6.1 resolved each tenant's
acl_labelsonto the principal, but nothing enforced them. NewAclPolicyEngine(rag-policy) — a decorator likeQuotaPolicyEngine— And-mergesany_in("acl_labels", sorted(principal.acl_labels))into everyread_chunkpush-down; a chunk is retrievable only when its labels overlap the principal's - Overlap via the existing
AnyInpredicate — the semantics the codebase already assumed and every backend translator already speaks (pgvector&&, QdrantMatchAny, CypherANY(), noopevaluate), so zero new predicate / translator changes. Injected at the canonicalHybridRetrieverread_chunkPDP site (already mergesfilter_pushdown), so no new call site / coverage-linter entry - Fail-closed: the clause is uniform —
any_in([])matches nothing, so a label-less principal retrieves nothing (model "public" as a shared label granted to all); each label-less request emits one PII-freeacl.egress_deniedevent (pre-registeredEVT_ACL_DENIED) - Opt-in via new
cfg.acl.enabled(default off → pre-6.3 behaviour, tenant scoping only);build_app(acl_enabled=…)wraps the engine,build_app_from_configfrom config. Decorates without absorbing the inner engine (composes with a production PDP) AclConfig→rag.schema; ~17 new tests incl. an end-to-end ACL red-team throughHybridRetriever(overlap / disjoint / fail-closed / public-label / no-tenant-bypass) + engine unit + event + wiring; all gates green (ruff, mypy --strict, RAG001, schema-drift, policy-coverage, log-schema)- Scope: push-down enforcement only — post-retrieval egress re-verification is 6.4, graph edge ACLs deferred. ADR-0035, architecture/policy-engine.md, reference/tenancy.md
6.4 — ACL egress verifier ✅ #151
- The 6.3 push-down filters ACLs at the source (one layer, re-implemented by each backend translator); 6.4 adds the defense-in-depth second layer that the
tests/policy/coverage.pylinter docstring already anticipated. NewAclEgressVerifier(rag_policy.egress) —verify(ctx, refs)keeps only the returnedChunkRefs whoseacl_labelsoverlap the principal's, dropping the rest — so a filter-translation bug, a backend that ignores the predicate, or a path wired without the policy engine cannot leak an over-privileged chunk past the boundary - Identical overlap semantics to the push-down (same
∩ ≠ ∅, same fail-closed / "public is a shared label" model), so it's a no-op on a correctly-filtered result and only ever acts on a genuine leak. ReadsChunkRef.acl_labels(which every backend populates regardless of the filter applied) → no re-hydration, an O(results) set-intersection per call (hot-path-safe) - Independent of the PDP — it consults only
ctx.principal.acl_labels, so it backstops the push-down even when the push-down isn't wired. Layered at the retrieval router boundary: a thinSupportsRoutewrapper (AclEgressVerifyingRouter, in the gateway sorag-retrievalkeeps its no-rag-policyproperty) decoratesapp.state.retrieval_router— the single attribute every surface reads — so query / retrieve / corpus / OpenAI / agent all inherit it, a layer above theHybridRetrieverwhere the push-down merges - Opt-in, on-by-default behind ACL: new
cfg.acl.verify_egress(default true) gated oncfg.acl.enabled— turning ACLs on gives both layers; set it false to run the push-down alone (the strict drop is meaningless without the label model, so it never fires for a non-ACL deployment).build_app(acl_verify_egress=…)/build_app_from_configwire it;app.state.acl_verify_egressreports the effective state acl.egress_violation(pre-registeredEVT_ACL_EGRESS_VIOLATION,errorlevel, PII-free: tenant / principal / counts / dropped chunk ids only) fires once per call that drops ≥1 ref — the unexpected push-down failure, distinct from 6.3's expectedacl.egress_denied; a clean pass is silent- Scope: gateway retrieval surfaces (everything reading
app.state.retrieval_router); trusts the labels the backend reports on eachChunkRef(catching a mislabelling backend needs authoritative re-hydration — deferred), per-tenant/per-label violation metrics deferred to the 6.x governance dashboards.AclConfig→rag.schemaregenerated; ~21 new tests incl. a red-team zero-violation-rate gate (tests/redteam/test_acl_egress_verifier.py— bypassed push-down + leaky backend → verifier drops every violation) + verifier unit + gateway wiring + event-schema; all gates green (ruff, mypy --strict 296 files, RAG001, schema-drift, policy-coverage, log-schema). ADR-0036, architecture/policy-engine.md, reference/tenancy.md
6.5 — PII policies ✅ #152
- Step 1.7 detects + redacts PII at ingest; Step 6.1 resolved each tenant's
pii_policyontoRequestContext.pii_policy; but nothing enforced it at egress — theegress_textPDP decision existed and the gateway already called it at every generation boundary, yet the default engine answeredallow. NewPiiPolicyEngine(rag-pii) — a decorator likeQuotaPolicyEngine/AclPolicyEngine— answersegress_textand applies the per-tenant action: allow delegates, redact / mask →transform, block →deny - Handles both subject shapes the gateway already passes — the retrieved context (
list[Chunk]→ the LLM on/v1/query?generate//v1/chat/completions/ gRPCConverse) and the agent's final answer (str) — so enforcement lands at all four existingegress_textcall sites with no route change and no coverage-linter entry (the existing deny→drop / transform→substitute handling consumes the result unchanged) - Reuses Step 1.7 machinery: detection via the injected
PIIDetectorSPI (default the dependency-freeRegexPIIDetector; production injects Presidio), rewriting via the sameredact_spans/mask_spans, and the samemin_score+entitiesfilter as the ingestPiiProcessor— so the two stages never disagree, and a clean scan is a no-op.blockon a chunk list is all-or-nothing (any PII denies the whole context); redact/mask rewrite each affected chunk'scontent(clean chunks pass through, same object) - Lives in
rag-pii(which gains arag-policydep, exactly asrag-quotadoes forQuotaPolicyEngine; graph stays acyclic —rag-policynever importsrag-pii).filter_pushdown/health/ non-egress_textdecisions delegate inward, so it composes with the ACL + quota PDP - Opt-in via new
cfg.pii.enabled(default off — it can redact / withhold content);build_app(pii_enabled=…, pii_detector=…)wraps the engine after the acl wrap,build_app_from_configfrom config;app.state.pii_enabledreports the state. Events are PII-free:pii.egress_blocked(block, the alertable denial — the reserved event from Step 0.7b now emitted) /pii.detected(redact/mask) carry entity types + counts + surface, never the matched values - Scope: enforces at the existing
egress_textsites (context for query/OpenAI/gRPC, answer for the agent). Deferred: a post-generation answer re-check for the query/OpenAI/gRPC generate paths (they sanitise the context pre-LLM) and a PII check on retrieval-only citations — stored chunks are already ingest-sanitised (1.7).PiiConfig→rag.schemaregenerated; ~24 new tests (engine unit: str/chunks × allow/redact/mask/block, min_score/entities, events, delegation; gateway wiring + composition with ACL + behavioral redact/block); all gates green (ruff, mypy --strict 297 files, RAG001, schema-drift, policy-coverage, log-schema/PII gate). ADR-0037, architecture/pii.md, architecture/policy-engine.md, reference/pii.md
Step 0.7c shipped the foundation — an append-only AuditStore SPI, a NoopAuditStore that links events into a SHA-256 hash chain, and an AuditWriter facade — but the log was write-only (only the corpus router wrote corpus.route, into a store nothing could read or verify). Step 6.6 makes it usable + provably intact, delivered as two vertical slices.
6.6a — Audit read API + chain verification ✅ #153
- One shared audit store.
build_appnow creates a singleAuditWriterover a hash-chain store, exposesapp.state.audit_store/audit_writer/audit_enabled, and hands the same writer to the corpus router — so the events the writers append land in exactly the store the read API serves (before this, the corpus router held a privateNoopAuditStorethe gateway couldn't read). NewAuditWriter.storeproperty is the read accessor; default store is the in-memoryNoopAuditStore(creds-free chain), production injects a durable one GET /v1/audit— tenant-scoped (a principal sees only its own tenant's events, filtered byctx.tenant_idat the boundary — same pattern asGET /v1/query/{id}/trace), newest-first, bounded bylimit(clamped[1,1000]), optionalaction/outcomefilters. TheAuditListResponsecarrieschain_verifiedso one read both returns the events and attests the log is untamperedGET /v1/audit/verify— whole-log hash-chain integrity (the chain is one global sequence across tenants), returning{ok, event_count}— a boolean + total, no event content, so global verification leaks nothing cross-tenant- The hash chain is the tamper-evidence mechanism (no second scheme):
verify_chain()recomputes every link and fails if any event or stored hash was altered. Immutability at rest (preventing deletion/replacement of the store) is the 6.6b WORM export's job - Read API on by default: new
cfg.audit.enabled(default true) — unlike the behaviour-changing ACL / PII toggles, the audit log is a passive, tenant-scoped compliance record, so exposing it out of the box is the expected enterprise default; off → endpoints 404 (AuditNotFoundError), events still recorded. NewAuditListResponse/AuditVerifyResponsewire types (rag_core.gateway_types);AuditError/AuditNotFoundError(→ 404);dist/openapi+dist/rag.schemaregenerated - Scope: read + verify only (WORM signed export is 6.6b); today the populated event is
corpus.route(every query) — expanding audit coverage (ACL / PII / ingest decisions) is a follow-up, the surface + chain are in place for it. ~10 gateway tests (tenant-scoped list, cross-tenant isolation, verify ok + tamper detection, filters/pagination, disabled→404, no-auth→401, shared-store wiring); all gates green (ruff, mypy --strict 298 files, RAG001, schema/openapi-drift, policy-coverage, log-schema). ADR-0038, architecture/audit-log.md, reference/audit.md
6.6b — WORM signed export ✅ #154
- The hash chain (6.6a) gives tamper-evidence for the live log; 6.6b adds immutability at rest via a self-verifying signed export. New
AuditExporter(rag_core.audit_export, pure stdlib) builds anAuditExportbundle with two independent integrity checks: acontent_hash(SHA-256 over the canonical serialisation of the events — pins the exact set + order, recomputed on verify so any add/remove/edit is caught even without the secret) and asignature(HMAC-SHA256 overf"{timestamp}.{content_hash}", mirroring the Step 5.1ProvenanceSignerscheme).verify()returns{signed, content_ok, verified, reason}withreason∈ok/unsigned/no_secret/content_mismatch/signature_mismatch;chain_verifiedattests the source store's whole-log chain was intact at export time POST /v1/audit/exportis tenant-scoped (the caller's events only — same isolation asGET /v1/audit);ragctl auditexports the whole log in-process. Because integrity iscontent_hash+ HMAC over whatever events are included (not the global chain head), the sameverify()works for both — a tenant subset isn't a contiguous chain. The bundle verifies offline (no live store), so it's the artifact an operator archives to immutable storage (S3 Object Lock)- Opt-in signing: new
cfg.audit.export_secret(env-interpolated, mirrorsprovenance.signing_secret); empty (default) → content-hashed but unsigned bundle (verify→unsigned), set it to enable signature verification.build_app(audit_export_secret=…)→app.state.audit_exporter;build_app_from_configfrom config - New
AuditExport/AuditExportSignature/AuditExportVerificationcore types (dist/schemasregenerated);POST /v1/audit/export→dist/openapi;ragctl audit(seed → export → verify round-trip,--outwrites the bundle,--verify FILEchecks one offline). ~23 new tests (exporter unit: content_hash determinism, sign→verify, unsigned, no_secret, wrong-secret, content tamper → content_mismatch, signature tamper → signature_mismatch, empty; gateway: tenant-scoped + signed + offline-verifies + cross-tenant isolation + unsigned + disabled→404 + no-auth→401; ragctl round-trip + file verify). All gates green (ruff, mypy --strict 299 files, RAG001, schema/openapi-drift, policy-coverage, log-schema). ADR-0038, architecture/audit-log.md, reference/audit.md - Deferred: a durable live-store
AuditStorebackend (Postgres / append-only ledger) and expanding what gets audited beyondcorpus.route.
New ground — the only prior crypto was HMAC signing. The V1 plan calls for envelope encryption with per-tenant data keys wrapped by a customer-controlled KMS KEK, zero-downtime rotation, and sealing (KEK unavailable → tenant data unreadable). Delivered in slices.
6.7a — Envelope encryption library + local KMS ✅ #155
- What's encrypted: chunk content / blobs at rest — not embedding vectors (ANN search reads vectors directly, so encrypting them would break retrieval; vectors carry no raw text). A deliberate, documented constraint
KeyManagerSPI (rag-core) over opaque bytes —encrypt(ctx, plaintext) → bytes/decrypt(ctx, ciphertext) → bytes; ciphertext is a self-describing envelope.NoopKeyManager(passthrough) for wiring tests.EncryptingStorage(rag-core, crypto-free) is aStoragedecorator that encrypts onput/ decrypts onget— the application seam over any backend.EncryptionError+KeyUnavailableError(sealing)EnvelopeKeyManagerbase (rag-backends,cryptography) does the data-key half once for every provider: a fresh AES-256-GCM DEK per payload (withctx.tenant_idbound as AAD), the small DEK handed to a subclass to wrap/unwrap with the KEK.LocalKeyManagerwraps with an in-process per-tenant KEK (dev / tests / air-gapped); cloud providers (6.7b) subclass and wrap/unwrap via the KMS API so the KEK never leaves the customer- Per-tenant isolation via KEK-per-tenant and tenant-bound AAD (even a shared KEK can't read another tenant's blob); sealing via
KeyUnavailableError(a tenant with no KEK is denied, others unaffected); tamper-evidence via the GCM auth tag.cryptographyinrag-backends;rag-corestays crypto-free (SPI + decorator + noop) - Scope: library + local KMS only. Deferred: cloud KMS providers behind
[kms-*]extras + wiringEncryptingStorageinto the ingest/storage path + per-tenant key config (6.7b); zero-downtime key rotation (6.7c); vector encryption is out of scope by design. ~19 new tests (KeyManager contract over noop + local; envelope round-trip / non-determinism / tamper / malformed / cross-tenant / shared-KEK-still-blocked / sealing on encrypt+decrypt / wrong-size KEK / per-tenant non-impact / EncryptingStorage stores ciphertext + delegates key ops) +ragctl kmssmoke; all gates green (ruff, mypy --strict 305 files, RAG001, policy-coverage, log-schema; no dist drift). ADR-0039, architecture/byok.md, reference/encryption.md
6.7b — Cloud KMS (AWS) + config / factory / wiring ✅ #156
- Makes BYOK configurable + provider-selectable. New
cfg.kms(enabled/provider∈noop/local/aws/default_key_id/region/local_key) +tenants[].kms_key_id(per-tenant key reference — a KMS ARN foraws, a hex 32-byte KEK forlocal).build_key_manager_from_config(cfg)factory (in the gateway, lazyrag-backendsimports sorag-backendsstays config-free) selects the provider;build_app(key_manager=…)exposesapp.state.key_manager+app.state.kms_enabled(passthroughNoopKeyManagerwhen disabled) AwsKmsKeyManager(rag-backends/kms/aws.py) subclassesEnvelopeKeyManagerand wraps/unwraps the DEK via AWS KMS Encrypt/Decrypt for the tenant's customer-managed key (over the already-presentaioboto3— no new dep). Per-tenant CMK from the key-id map (+default_key_id); any KMS failure (revoked / denied / unreachable) or a missing key id →KeyUnavailableError(sealing). An injectableclientseam makes it fully unit-testable with a fake KMS — no AWS creds / network- Scope: AWS provider + config + factory + the
app.state.key_managerseam. Deferred: GCP KMS / Azure Key Vault / HashiCorp Vault providers + key rotation (6.7c); wiringEncryptingStorageinto the ingest path (blocked on tiered-storage plumbing — chunk content is inline today). ~14 new tests (AWS provider over a fake KMS: round-trip / KMS-key-bound cross-tenant block / sealing / default-key / KMS-error mapping; config defaults; factory noop/local/aws; gatewayapp.state.key_manager+kms_enabled+ a local round-trip).KmsConfig→rag.schemaregenerated; all gates green (ruff, mypy --strict 306 files, RAG001, policy-coverage, log-schema). ADR-0039, reference/encryption.md, architecture/byok.md
6.7c — GCP / Azure / Vault KMS providers ✅ #157
- Completes the four-provider KMS matrix — each subclasses
EnvelopeKeyManager(inheriting the DEK + AES-GCM + tenant-AAD + envelope framing) and only wraps/unwraps the DEK via its KMS API:GcpKmsKeyManager(google-cloud-kmsencrypt/decrypt),AzureKeyVaultKeyManager(azure-keyvault-keyswrap_key/unwrap_key, RSA-OAEP-256; aCryptographyClientper key via a cached factory since Azure binds a client to one key),VaultKeyManager(HashiCorp Vault Transitencrypt_data/decrypt_data, synchvacrun in a thread,mount_point) - Each is behind a
[kms-gcp]/[kms-azure]/[kms-vault]extra (the SDKs are lazy-imported, so the modules import without the SDK and selecting a provider without its extra raises a clearImportError); an injectableclient/client_factoryseam makes every provider fully unit-testable with a fake KMS — no cloud creds / network. Connection uses each SDK's standard credential discovery (GCP ADC, AzureDefaultAzureCredential, VaultVAULT_ADDR/VAULT_TOKEN); per-tenant key resolution + sealing (KeyUnavailableError) are uniform with AWS KmsProviderenum +build_key_manager_from_configfactory +KmsConfig.vault_mountextended. mypy overrides added for the three SDK module trees (absent at lint time). Scope: providers only — key rotation is 6.7d; wiringEncryptingStorageinto the ingest path stays deferred (tiered storage). ~18 new tests (each provider over a fake client: round-trip / cross-tenant block / sealing / SDK-error mapping; factory cloud-provider-requires-extra contract).KmsConfig→rag.schemaregenerated; all gates green (ruff, mypy --strict 309 files, RAG001, policy-coverage, log-schema). ADR-0039, reference/encryption.md, architecture/byok.md
6.7d — Zero-downtime key rotation ✅ #158
- Closes Step 6.7. New
RotatingKeyManager(rag-core, a crypto-freeKeyManagerdecorator) makes rotating a tenant's KEK seamless: encrypt always uses the current key; decrypt tries the current key then each non-expired retired key (RetiredKey(key_manager, expires_at)). The try-all decrypt is safe, not a guess — AES-GCM authenticates the DEK, so a wrong KEK can't yield a valid key (only the KEK that wrapped a blob decrypts it) rewrap(ctx, ct)is the background-migration primitive (decrypt with whatever key still works → re-encrypt under the current key); once all blobs are migrated a retired key is dropped. An expired retired key is skipped, so old un-rewrapped data is sealed (EncryptionError) — "retain decrypt-only until expiry" — with no impact on current-key data. Injectableclockfor deterministic expiry tests- Composes with every provider (local + all four cloud KMS) since it orchestrates only the
KeyManagerSPI.ragctl kms --rotatedemos the full flow (old + new decrypt, rewrap, expiry seals old data) - Scope: the rotation mechanism (satisfies the planning rotation acceptance — rotate → old + new both decrypt; expired key seals old data). Deferred: config-driven per-tenant multi-generation rotation + the storage-side background re-encryption job land with the
EncryptingStorageingest wiring (tiered storage). ~11 new tests (rotate → old+new decrypt; rewrap migration; expired-key sealing; tamper; no-key sealing; multi-generation; clock-controlled expiry; health) +ragctl kms --rotate. No dist drift; all gates green (ruff, mypy --strict 310 files, RAG001, policy-coverage, log-schema). ADR-0039, reference/encryption.md, architecture/byok.md
6.8 — SSO / SCIM ✅ #159
- The seam is the
AuthSPI. The gateway middleware already callsauth.authenticate(bearer_token, tenant_id) → Principalat the boundary (the one SPI method that runs before aRequestContextexists). NewFederatedAuth(rag-sso) implements it, dispatching to a per-tenantOidcProvider/SamlProvider, so wiring it as the gateway'sauthbackend is the entire integration — no middleware change. The returned principal'sacl_labelscome from the IdP's group claims, so Step 6.3 ACL push-down + 6.5 PII egress govern federated users unchanged (authorizestays a coarse allow — federation establishes who, the PolicyEngine decides what) - Dependency-free defaults, heavy crypto behind extras (mirrors BYOK / NLI). OIDC verification is a real stdlib HS256 JWT verifier (
verify_jwt: split → headeralgallowlist check → constant-timehmac.compare_digest→exp/nbf/iss/audwith leeway); asymmetric RS256 / ES256 delegates to PyJWT behind the[oidc]extra against a configured public key. SAML parses throughdefusedxml(a core dep — XXE / billion-laughs safe), validates Issuer / Conditions / AudienceRestriction, and injects XML-DSig verification (signxml_verifier,[saml]extra) —require_signatureon with no verifier fails closed - Algorithm-confusion designed out: the
algallowlist rejectsalg:noneand an RS256 token replayed as HS256; symmetric vs asymmetric take different key material (hmac_secretvspublic_key) - Per-tenant IdP config on
tenants[].sso(reuses the Step 6.1 mechanism;${ENV}-interpolated secrets). A tenant with nossoblock has no provider → its bearer tokens are rejected (fail-closed) while header-identity dev flows still work.build_federated_auth_from_configbuilds one provider per tenant;NoopAuthwhencfg.sso.enabledis off (pre-6.8 behaviour) - SCIM 2.0 is a separate surface with its own auth. New tenant-scoped
ScimStoreSPI +NoopScimStore(CRUD forScimUser/ScimGroup, isolation = the store key) +ScimService(uniqueness, server id +meta, the IdP deactivation PATCH,attr eq "value"filter, PII-freescim.*events)./scim/v2/{Users,Groups}+ discovery endpoints authenticate a per-tenant SCIM bearer token (cfg.scim.tokens, constant-time compare — not a user JWT), return SCIM-shaped errors (RFC 7644), and 404 when disabled (checked before auth, so the surface is hidden). No new governed SPI call → the PolicyEngine coverage linter passes with no allowlist entry - New core types
FederatedIdentity/SsoProtocol/ScimUser/ScimGroup(+ nested) +SsoError(401) /ScimError(400) /ScimNotFoundError(404) /ScimConflictError(409); wire typesScimListResponse/ScimPatchOp/ScimErrorBody/SsoStatusResponse(dist/schemas+dist/openapiregenerated);cfg.sso/cfg.scim/tenants[].sso(dist/rag.schema); PII-freesso.*/scim.*events (subject hashed, never email / userName);ragctl sso(list + in-process OIDC demo) +ragctl scim(in-process provisioning demo) - Scope: verification + provisioning at the boundary. Deferred: remote JWKS discovery + rotation (configured static keys only), SAML SP-initiated redirect + metadata, SCIM bulk /
/Me/ ETag, directory-backed deprovisioning at authenticate-time, the admin-console SSO/SCIM card. ~80 new tests (jwt / oidc / saml / federated-auth / scim-service unit;ScimStorecontract suite; gateway SCIM CRUD + token guard + isolation + disabled-404; SSO status + JWT-through-middleware;ragctl). All gates green (ruff, mypy --strict, RAG001, schema/openapi/config-drift, policy-coverage, log-schema/event-registry/PII). ADR-0040, reference/sso.md, architecture/sso-scim.md
6.9 — Air-gapped install bundle ✅ #161
- Ships the platform as one signed, self-contained artifact for networks with no registry / internet: all runtime container images (
docker save), the packaged Helm chart (helm package), an examplerag.yaml, and a standalone offline installer. Packages what already exists — the gateway image (built from./Dockerfile, signed keyless indocker.yml) + the chart (0.9) + the runtime image set (docker-compose) - Integrity = a standard
SHA256SUMS+ optional cosign (mirrors the 6.6b WORM export). Acontent_hash(SHA-256 over the canonicalSHA256SUMS) is always present for tamper-evidence and is verifiable with nothing butsha256sum -c— no network, no cosign — and is pinned inmanifest.json. A cosign signature overSHA256SUMSadds authenticity when present. The sameSHA256SUMSdrives both the Python verifier and the standalone shell installer, so they can never disagree;manifest/SHA256SUMS/*.sig/*.pemare excluded from the hashed payload - Standalone
install.{sh,ps1}need no Python workspace — an air-gap host has bash/pwsh + docker + helm (+ maybe cosign / sha256sum), not uv or the repo. Shipped inside every bundle, they verify →docker load images.tar→helm upgrade --install;ragctl airgap installis the same flow for operators who have the toolchain.--verify-onlychecks integrity without installing - Build/verify logic in
ragctl.airgap(underpackages/, somypy --strict+ unit-tested): a pure core (BundleManifestmodel,render_sha256sums/content_hash/build_manifest/verify_bundle) cleanly separated from the subprocess seam (docker / helm / cosign through one_runchokepoint). A--dry-runproduces a complete, verifiable bundle minus the image blobs, so the whole packaging + verification path is testable with no Docker - Digest-pinned, manifest-driven image set:
infra/airgap/images.txtlists the third-party images (tags); the build resolves each to its digest intomanifest.json. The gateway image is added from the chart'simage.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.ymlonv*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, reference/airgap.md, architecture/airgap-bundle.md, guides/airgap-install.md
6.10 — Compliance posture ✅ #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-compliancepackage (deps rag-core + rag-observability only, config-free — like rag-feedback / rag-drift):RetentionEnforcerdrives tenant-scopedpurge_*(purge= per-class windows,erase_tenant= GDPR right-to-erasure);compliance_posture/residency_okare 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)onFeedbackStore+ProvenanceStore(default no-op → no backend breaks).dry_runlives in the SPI so a preview counts without deleting uniformly (ProvenanceStorehas nolist). The audit chain is never purged in place (it would break tamper-evidence) — audit retention is the Step 6.6b WORM export;RetentionPolicy.audit_daysis advisory - Right-to-erasure —
POST /v1/compliance/eraseerases 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 needsdry_run=falseandconfirm=true - Data residency —
tenants[].data_regionresolves ontoTenantSettings(reuses the 6.1 resolver) and is enforced at ingest againstcfg.compliance.region; a mismatch raisesResidencyViolationError(→ 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/complianceserves aCompliancePosture(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);ComplianceEraseRequestwire type;cfg.compliance+tenants[].data_region/retention_days(dist/rag.schema); PII-freecompliance.*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 isragctl/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, reference/compliance.md, architecture/compliance.md, compliance/soc2-control-mapping.md, compliance/gdpr-mapping.md
| Step | Title | Status | Planned deliverables |
|---|---|---|---|
| 7.1 | Load testing | ✅ | #164 — chaos-under-load CI gate (eval/gateway_chaos_v0 fault backends + harness; tests/perf/test_chaos_under_load.py; graceful degradation: no 5xx + breaker opens); Locust v1 suite (varied-query mix + ramp shape); acceptance targets + runbook |
| 7.2 | Chaos engineering | ✅ | #165 — in-process kill-matrix gate (kill each hot-path backend → no 5xx, breaker opens, expected degraded shape); two new gateway degrade-open guards (retrieval cache + reranker); LitmusChaos cluster manifests (infra/chaos/) + task chaos-kill |
| 7.3 | Red-team | ✅ | #166 — adversarial probe gate (injection / PII / ACL / tenant-escape) under a redteam marker + redteam-gate CI job; new rag-injection guard (≥ 95 % block, no untrusted chunk in a system-trust position) wired on every answer surface; PII-egress probe; task redteam |
| 7.4 | Design partner onboarding | ✅ | #174 — repo-backed pilot program: operator runbook + 5 templates (docs/pilots/) + ADR-0046; customer-support/KB kit (corpus w/ PII + planted injection probe; domain criteria); ragctl pilot onboard + report (weekly KPIs + PASS/FAIL verdict from feedback/drift/cost signal components, KPIs pulled not self-reported); worked case study (real pilot report → PASS). Signing partners = external GTM item |
| 7.5 | Documentation site | ✅ | #178 — Docusaurus app (website/) serving docs/ in place; generated + drift-gated REST API reference from dist/openapi.json; tests/docs/ quickstart-honesty suite + lychee/codespell; docs CI workflow; task docs:api/docs:build |
| 7.6 | Marketplace listings | ✅ | #179 — marketplace/ listing artifacts (AWS/Azure/GCP); one canonical pricing.yaml (dims = 7.9 metering signals) + shared listing copy mapped to all three; reuse Helm/AMI/airgap/GHCR delivery; submission checklists + procurement test; approval = external process |
| 7.7 | Packaging & distribution | ✅ | #180 — rag-platform PyPI meta-package; release.yml fans one tag out to PyPI/npm/GHCR(admin-ui)/Helm-OCI (cosign-keyless + SPDX SBOM + OIDC/provenance) alongside docker.yml+release-airgap.yml; apps/admin-ui/Dockerfile; channels matrix + guide |
| 7.8 | Support & SLA | ✅ | #181 — support tiers + SLA targets (= pricing.yaml); docs/runbooks/ with a runbook per alert type (tied to real events / /v1/status/* / SLOs); incident-response + postmortem template; status page from measured health; PagerDuty via the Step 3.9 webhook system (no new code) |
| 7.9 | Billing integration | ✅ | #182 — rag_observability.billing: observe-only UsageMeter (5 dimensions) + GET /v1/billing/usage; pure generate_invoice priced from pricing.yaml (reconciles ±0.5%); BillingProvider Stripe/marketplace seam; cfg.billing; ragctl billing; mirrors 5.6c (no dist/schemas churn) |
| 7.10 | GA cutover | ✅ | #183 — v1.0.0: SemVer + deprecation policy (ADR-0052); release notes; GA readiness checklist (all exit gates, external items flagged); rag-platform meta → 1.0.0; v1.0.0 tag fans out to every channel |
7.1 — Load + chaos testing ✅ #164
- Builds on Step 4.6 (ADR-0025: the in-process p99-overhead gate + Locust v0). 7.1 adds the load + resilience side the GA bar needs, split into a deterministic CI gate + a cluster runbook
- Chaos-under-load is a deterministic CI gate. New
eval/gateway_chaos_v0/drives the in-process gateway (httpx ASGITransport, no socket / no cluster) under concurrent load while injecting backend faults, and asserts the gateway degrades gracefully — no 5xx, 100% success, the failing backend's circuit breaker opens. The asserted property is resilience, not throughput, so it is timing-independent and runs in theperfCI job (tests/perf/test_chaos_under_load.py);task chaos-testruns the harness--check - Reuses the Phase-4 machinery — it validates it.
FaultSpec(seeded failure probability + injected latency) +Chaos{Vector,Keyword,Graph}RetrievalBackendare SPI wrappers exactly like the rag-breaker wrappers (allowlisted in the policy-coverage linter for the same reason: they forward the already-policy-mergedretrieve_ids/expand), wired behind real breakers + the fallback ladder. Under 100% vector failure the vector breaker trips and the fallback fuses the survivors; under all-backends-fail it degrades to graceful empty answers — the client never sees a 5xx - The query is varied per request (a unique nonce) in both the chaos harness and the Locust suite — identical queries hit the retrieval cache and short-circuit before retrieval, so a constant-query load test would measure the cache, not the pipeline (and the chaos backends would never be exercised)
- The 1000-QPS / p99 < 500 ms acceptance is a cluster runbook, not a CI gate (hardware/backends-bound — same reasoning as ADR-0025). Extended Locust v1 suite (
eval/gateway_load_v0/locustfile.py): a weighted read/write mix (/v1/retrieve·/v1/query·/v1/feedback·/v1/status/metrics·/healthz) + aLoadTestShapethat ramps → holds a plateau → ramps down (env-tunable peak / hold for distributed workers). Acceptance targets + the runbook inguides/load-testing.md - Scope: in-process chaos gate (resilience) + the Locust suite + runbook for the throughput acceptance. Deferred: distributed-Locust-in-CI against an ephemeral cluster, latency-based breaker tripping (timeouts), storage/LLM-layer fault injection, soak/endurance tests (cluster-level chaos with LitmusChaos is Step 7.2). ~4 chaos perf tests; all gates green (ruff, mypy --strict, RAG001, policy-coverage, schema/openapi-drift, log-schema). ADR-0043, guides/load-testing.md, reference/perf.md
7.2 — Chaos engineering ✅ #165
- Builds on Step 7.1 (ADR-0043). 7.1 fault-injected the three retrieval backends under load; 7.2 closes out the deliverable — "kill each backend; verify the fallback chain holds" — and the LitmusChaos piece 7.1 deferred. The GA bar is a sharp invariant: no single backend failure can 5xx the gateway
- The kill-matrix is a deterministic in-process CI gate.
eval/gateway_chaos_v0/kill_matrix.pywalks the full hot-path backend set — vector / keyword / graph / embedder / retrieval_cache / reranker / llm — kills one at a time (100% unavailable) behind the real Phase-4 breakers + fallback + the gateway degrade guards, drives/v1/query, and asserts no 5xx, 100% success, the killed on-path retrieval breaker opens, and the expected degraded shape (tests/perf/test_chaos_kill_matrix.py,perf-marked + timing-independent;task chaos-kill). A tiny seeded keyword corpus + the store's realhydratemake rerank/pack/generate actually run, so every kill exercises a genuine degrade path - Chaos fixes what it finds. Running the matrix exposed two ways to 5xx: a down retrieval cache (
get_best/put_with_query) and a down reranker (reranker.rerank) propagated unhandled. Both gained minimal degrade-open guards at the call site (matching the existing LLM guard): the cache treats an outage as a miss (gateway.cache.degraded, on/v1/query+/v1/retrieve); the reranker falls back to retrieval-only (gateway.rerank.degraded, honouringRerankPipeline's documented "caller decides" contract — the reranker is also the hydration stage, so the degraded response carries no citations, same shape asrerank=false). The LLM (answer-generation guard) + embedder (understanding's per-component catch) already degraded - Graph is off the default read path.
RetrievalRouter.decideonly setsuse_graphwhen the caller supplies graph seeds; a standard query never does, so killing graph cannot affect it (breaker stays closed) — the matrix records this honestly (no 5xx asserted, breaker-open not required) rather than forcing a synthetic graph query - Cluster chaos is a runbook, not a CI gate (topology-bound).
infra/chaos/LitmusChaos manifests: gatewaypod-delete(PDB-protected instance loss) +pod-network-loss/pod-network-latencytoward a backend host (the cluster way to "kill a backend", since backends are reached by host), each with an embedded httpProbe asserting the gateway stays 200 throughout - Scope: kill-matrix gate + the two degrade-open hardenings + the LitmusChaos runbook. No new core/wire types, SPI methods, config, or events (
dist/untouched; the degrade kinds are log-only likegateway.answer.failed; the kill wrappers are pure-raise so the policy-coverage linter needs no new rule). Deferred: latency-based breaker tripping, multi-backend simultaneous kills as a gate, Litmus-in-CI against an ephemeral cluster, soak chaos. 12 kill-matrix perf tests; all gates green (ruff, mypy --strict 325 files, RAG001, policy-coverage, schema/openapi-drift, log-schema). ADR-0044, guides/chaos-engineering.md, reference/perf.md
7.3 — Red-team / security ✅ #166
- The governance stack (ACL 6.3/6.4, PII 6.5, tenancy 6.1/6.2, hallucination guard 4.3) becomes an adversarial probe gate across four classes. ACL bypass + tenant escape were already gated (
tests/redteam/test_acl_*,test_cross_tenant_*); 7.3 adds prompt injection + PII egress and consolidates all four under aredteammarker (atests/redteam/conftest.pyauto-marks the directory — scoped to its own subtree so it can't mark the whole suite) + a first-classredteam-gateCI job (task redteam) - The injection gap was real.
trust_levelwas stored + filterable everywhere but nothing enforced it at the LLM adapter — a retrieved document could carry "ignore your instructions, you are now…" straight to the model, and the OpenAI-chat surface assembled context as arole="system"message (an attacker-controlled system-trust position, which PROBLEM-TRACEABILITY forbids) - New
rag-injectionpackage (deps rag-core + rag-observability, mirroring rag-guard/rag-pii): a pluggableInjectionDetector(dependency-freeHeuristicInjectionDetector— a regex library anchored on the grammar of an attack: an imperative verb aimed at the model + its instructions/role/safety, within a bounded sentence-local gap, so benign prose mentioning "system"/"prompt"/"instructions" isn't flagged) +PromptInjectionGuard.inspect(ctx, chunks)that drops chunks scoring ≥ a per-tenant threshold (degrade-open; PII-freeinjection.blocked). Paired withINJECTION_RESISTANT_SYSTEM_PROMPT+build_user_messageso retrieved content can only ever appear as fenced untrusted data in the user turn — never a system turn. Wired into every answer surface (/v1/query,/v1/chat/completions, MCP); off by default (cfg.injection) - Deterministic CI gate, honest numbers.
eval/redteam_v0/injection_corpus.py— 568 known + 630 generated payloads across all categories + a benign control set.tests/redteam/test_prompt_injection.pyasserts 96.6 % block (≥ 95 % bar), 0 false positives (≤ 5 % bar), and the no-system-position invariant end-to-end with a capturing LLM on both surfaces. Building the corpus hardened the detector — it exposed ~20 missed phrasings (e.g. "ignore your previous instructions"), the point of red-teaming. The corpus is broader than the detector's patterns (carriers, morphology, a few evasive payloads) so the rate is honest, not a circular 100 % - PII-egress probe (
test_pii_egress.py): synthetic high-PII corpora through the 6.5PiiPolicyEngine—blockdenies,redact/maskstrip every span (verified by an independent second detector), over answer-text +list[Chunk]shapes - Scope: the injection guard + the four-class probe gate +
task redteam+ theredteam-gateCI job. Dependency-scan CVE gating is the existingpip-auditauditjob; the external pentest is a documented human-process item (guides/red-team.md), not code. Injection types are package-local + internal (no attacker signal on the wire;dist/schemas/openapiuntouched). Deferred: a real ML injection classifier behind the seam, per-tenant pattern tuning, nightly staging runs. ~23 new tests (12 package-local + 11 red-team) on top of the existing suite; all gates green (ruff, mypy --strict 329 files, RAG001, policy-coverage, schema/openapi-drift, log-schema/event-registry). ADR-0045, guides/red-team.md, reference/injection.md
7.4 — Design-partner pilots ✅ #174
- Step 7.4 is a GTM/process step made checkable: signing 3 referenceable design partners is an external human deliverable (like the 7.3 external pentest), so the repo ships the machine — a repeatable program whose KPIs are pulled from the platform's own signals, not self-reported. Delivered as one PR sliced 7.4a–d.
- 7.4a — framework. The operator runbook (guides/design-partner-pilots.md): pilot lifecycle (qualify → onboard → run → review → graduate), roles, onboarding-as-configured-deployment, the KPI→signal mapping, the intake → triage → incorporate → close feedback loop, acceptance (signed criteria green ≥ 3 consecutive weeks). Five fill-in templates under
docs/pilots/templates/(onboarding-checklist / success-criteria / weekly-kpi / feedback-log / case-study). ADR-0046. - 7.4b — customer-support/KB kit.
docs/pilots/customer-support/— a 3-doc sample corpus calibrated to exercise each criterion: an HR handbook carrying synthetic PII (egress demo), a product FAQ (grounded answers), and a security overview hiding a planted prompt-injection payload (the 7.3 guard must drop it). Domain-calibrated success criteria (faithfulness 0.92, a deflection KPI); framework stays vertical-extensible (legal / engineering kits deferred). - 7.4c —
ragctl pilottooling.pilot onboardrenders the per-tenantrag.yamlblock + onboarding checklist;pilot reportseeds a representative stream into the platform's own signal components (rag_feedbackaggregate /rag_driftregistry /rag_observability.CostTracker— the same ones behindGET /v1/status/{feedback,drift,cost}) and prints the weekly-KPI dashboard + a PASS/FAIL verdict (satisfaction ≥ +0.5, zero drifting monitors, cost not elevated); quality + latency cross-refragctl eval/perf. Seed-then-report (no infra), the same shape asragctl feedback/drift. 3 new ragctl tests. reference/pilot.md. - 7.4d — worked case study. docs/pilots/customer-support/case-study.md runs the framework end-to-end on the kit with real
ragctl pilot reportoutput (satisfaction +0.733, 0/5 monitors drifting, cost ok → PASS) + the PII-redaction + injection-blocked security demonstration — framed honestly as a representative pilot (a real partner substitutes their corpus + users). - Scope: the framework + the first vertical kit + the
ragctl pilotreader + the worked case study. No new package / core or wire type / governed SPI call /distchange —pilotis a pureragctlreader over existing signal components, so the policy-coverage + schema/openapi-drift gates need no new entry. Deferred / external: sourcing + signing the real referenceable partners; the legal + internal-engineering kits; per-pilot golden-set generation tooling; a pilot KPI card in the admin console. All gates green (ruff, mypy --strict, RAG001, link-check). ADR-0046, guides/design-partner-pilots.md, reference/pilot.md.
7.5 — Documentation site ✅ #178
- A public docs site that can't drift from the repo: a Docusaurus 3 app under
website/whose docs plugin points at../docs(routeBasePath: '/'), so the PR-revieweddocs/tree is the site — no copy step.markdown.format: 'detect'renders hand-written.mdas CommonMark (no MDX escaping of</{). - The REST API reference is a generated, drift-gated artifact.
scripts/gen_api_reference.py(pure stdlib) renders the 43-pathdist/openapi.json→docs/reference/rest-api.md; thedocsCI workflow regenerates + diffs it, exactly likedist/schemas/ the proto stubs — so the published API docs always match the gateway contract.task docs:apiregenerates. - Doc honesty is tested, not asserted.
tests/docs/extracts everyragctl <subcommand>and/v1/...path from the quickstart guides and fails if any isn't a registered CLI command / a real OpenAPI path — the lightest deterministic form of "every quickstart is exercised", no services needed (building it caught + fixed a newline-spanning regex false-positive). - New
docsCI workflow (.github/workflows/docs.yml): API-reference drift · quickstart doc-tests ·codespell·lychee --offlinelink-check · fullnpm run build.task docs:dev/docs:buildrun/build locally. - Scope: the site scaffold + generated reference + doc-tests + CI +
tasktargets. No code /distchange beyond the new generated reference file. Deferred: Algolia DocSearch, versioned docs aligned to releases, the deploy workflow + custom domain, gRPC/MCP reference auto-generation, executable (vs existence-checked) snippets. Verified locally (the npm site build is a CI step; the Python generator + the 3 doc-tests run green). ADR-0047, guides/documentation-site.md.
7.6 — Marketplace listings ✅ #179
- Listing artifacts for the three clouds under a new
marketplace/. As with the 7.3 pentest + 7.4 partners, approval + procurement integration are an external per-cloud process — the repo ships the submission inputs, not a faked "live" status. - One pricing model, three listings.
marketplace/pricing.yamlis canonical (Community / Pro / Business / Enterprise tiers + usage-metered dimensions); its dimension ids match the Step 7.9 metering signals one-to-one, so prices reconcile with metered usage and a price change is a one-file edit.marketplace/listing-content.mdis the shared copy (title / features / SLA / support / terms) the per-cloud files reference — listings can't drift from each other. - Reuse existing delivery — no new packaging. AWS (SaaS + AMI), Azure (SaaS + managed app), GCP (SaaS + K8s app) each map the model to that cloud's billing primitives and deploy via the existing Helm chart (
infra/) + air-gap bundle (6.9) + GHCR/OCI artifacts (7.7). The per-cloud Marketplace wrappers (packer AMI, Azure ARM, GCPschema.yaml) are thin shells, skeletoned. - Scope:
marketplace/(canonical pricing + shared copy + three per-cloud specs + checklists) + the process guide. No code /distchange. Deferred: the Marketplace wrapper artifacts, the procurement/metering API integrations, and the actual submissions/approvals (external). Pure docs/manifests, link-checked. ADR-0048, guides/marketplace-listings.md.
7.7 — Packaging & distribution ✅ #180
- The acceptance bar — one-command install from every channel, all artifacts signed + SBOM-attested — met by adding a server meta-package + a release path that fans one tag out to every channel with the same signing posture the gateway image already had.
rag-platformPyPI meta-package (packaging/rag-platform/) pins the workspace component distributions, sopip install rag-platform==Xresolves a reproducible server stack (optional backends + cloud KMS as extras). TheagentcontextosSDK stays a separate, lighter dist; the existingagentcontextos/@agentcontextos/sdkscope is kept — no rename (the plan's aspirationalragplatformnames would break existing consumers).- One tag → every channel. A
vX.Y.Ztag triggersdocker.yml(gateway image, cosign + SBOM, already shipped), the newrelease.yml(PyPI Trusted Publishing for the meta + SDK · npm with provenance · the admin-ui image via a newapps/admin-ui/Dockerfile, cosign + SBOM · the Helm chart pushed to OCI + cosign-signed), andrelease-airgap.yml(signed offline bundle). Kept as separate workflows so each re-runs independently. - Signed + SBOM, keyless. cosign keyless (GitHub OIDC) for images + chart, SPDX SBOM per image, OIDC Trusted Publishing for PyPI,
--provenancefor npm, cosign-over-SHA256SUMSfor the air-gap bundle — no long-lived registry tokens. - Scope: the meta-package +
release.yml+ the admin-ui Dockerfile + a channels-matrixpackaging/README.md+ the guide. Deferred: publishing the generated Go/Java/.NET SDKs; separateworker/eval-runnerimages (single gateway process today;coreis a library, not an image); the one-time trusted-publisher / GHCR setup + first publish (external). ADR-0049, guides/packaging-distribution.md.
7.8 — Support, SLAs & on-call ✅ #181
- The support + ops layer, grounded in the signals the platform already emits rather than a new alerting subsystem.
- Support tiers + SLA (
support-sla.md): Community / Pro / Business / Enterprise with response targets; per-tier availability + latency SLAs that matchmarketplace/pricing.yaml(sold = promised = measured); the follow-the-sun on-call + escalation. - A runbook for every alert type (
docs/runbooks/alerts.md): acl/tenant escape ·breaker.opened·drift.detected· cost anomaly ·quota.exceeded· injection spike · gateway degraded · latency/availability SLO ·eval.regression· ingest failures — each names the exact event //v1/status/*surface / SLO that raises it, a diagnosis path, a mitigation (often the existing self-healing), and an escalation. - Incident response + postmortem (
incident-response.md+postmortem-template.md): severity ladder, IC/Comms/Ops roles, the detect→resolve flow, and a status page that reflects measured health (/v1/status/health+ Grafana SLOs, Step 3.11/5.6e) — not manual toggles. - Paging reuses the Step 3.9 webhook system — PagerDuty is a subscriber for
audit.policy_violation/drift.detected/eval.regression(Events API v2), no new code. - Scope: the guide + the
docs/runbooks/area + this ADR. No code/distchange. Deferred: the hosted status-page deploy, the PagerDuty account + routing key, staffing the rotation (people process). ADR-0050, runbooks/.
7.9 — Billing & metering ✅ #182
- Per-tenant usage accounting for money — a third concern beside cost-anomaly detection (5.6c) and quota enforcement (4.5), mirroring the 5.6c shape to avoid churn.
- Dataclasses in
rag-observability, nodist/schemaschurn.rag_observability.billingholds aUsageMeter(thread-safe per-tenant, per-dimension counters —queries/docs_ingested/storage_gb/reranker_calls/llm_tokens),UsageSnapshot, and the pricing/invoice types; the gateway wraps the snapshot in a gateway-local PydanticBillingUsageResponse(likeCostStatusResponse). Onlydist/openapi(the new endpoint) +dist/rag.schema(the newcfg.billingblock) regenerate. - Observe-only metering; enforcement stays quotas. Fed O(1) from
record_request_usage(queries + tokens), independently of quotas, making no governed SPI call (the policy-coverage linter needs no entry — same as cost/drift/feedback).GET /v1/billing/usageserves the per-tenant usage dashboard; inert by default (cfg.billing). - Invoicing is offline, pure, priced from config.
generate_invoice(snapshot, pricing, tier)is pure;PricingModel/load_pricingreadmarketplace/pricing.yaml, so a price change is config (the shared 7.6/7.9 bar). Each metered line item's quantity equals the metered overage, so bills reconcile with usage ±0.5% by construction (the acceptance) —ragctl billingprints the invoice + the reconciliation check. - External billers are a Protocol seam.
BillingProvider.report_usage(Stripe usage-records / marketplace metering APIs) with aNoopBillingProviderdefault, called degrade-open — norag_core.spiceremony. - Scope: the billing module + the usage endpoint +
cfg.billing+ wiring +ragctl billing+ tests (unit reconciliation + gateway endpoint + CLI). All gates green (ruff, mypy --strict 330 files, config/openapi-drift regenerated). Deferred: the concrete Stripe/marketplace adapters, the docs/reranker/storage feed points, an admin usage card, subject-level invoicing. ADR-0051, reference/billing.md.
7.10 — GA cutover ✅ #183
- General Availability —
v1.0.0— the close of an 8-phase, 84-step build (Phase 0 foundations → Phase 7 GA). - SemVer from GA. MAJOR/MINOR/PATCH over the drift-gated public contracts (REST/OpenAPI · gRPC
rag.proto·rag.yamlschema · SDKs ·ragctlCLI), so a breaking change is caught in CI and forces a deliberate MAJOR bump; a deprecation cycle of ≥ 1 MINOR precedes removal (ADR-0052). Therag-platformmeta-package carries the platform version (bumped to 1.0.0). - The GA bar is a checklist of gates, not a declaration.
ga-readiness-checklist.mdmaps every Phase 7 exit gate to a CI gate or an in-repo artifact; inherently-external items — external pentest, SOC 2 Type II audit, the first publish to each registry, signing 3 referenceable customers, on-call staffing + the hosted status page, and the launch announcement — are marked (external) and ride on top, the same split used throughout Phase 7. - One tag, every channel.
v1.0.0is tagged onmain; the existing release workflows (docker.yml+release.yml+release-airgap.yml, Step 7.7) fan it out to PyPI / npm / GHCR / Helm-OCI / the air-gap bundle, all cosign-signed + SBOM-attested. - Scope: the GA release notes + readiness checklist + ADR-0052 + the meta version bump + the
v1.0.0tag. Deferred / external: the checklist's (external) items + the V1.1 backlog (no-code agent builder, multimodal outputs, on-prem GPU autoscaling, marketplace partner connectors, domain packs). release-notes/v1.0.0, ga/ga-readiness-checklist.
Complete log of every PR. Routine Dependabot bumps are grouped; everything else is listed individually.
| PR | Merged | Title |
|---|---|---|
| #1 | 2026-05-21 | Problems To Start With |
| #2 | 2026-05-21 | Add V1 execution plan, logging standard, GUI spec, and doc restructure |
| #3 | 2026-05-21 | feat: Phase 0 Step 0.1 — monorepo and build system |
| #4 | 2026-05-22 | Phase 0 Step 0.2 — Core domain types & errors |
| #5 | Closed | Duplicate merge of #4 (closed, no-op) |
| #6 | 2026-05-22 | feat(tooling): Phase 0 Step 0.2c — cross-platform support |
| #7 | 2026-05-22 | feat(core): Phase 0 Step 0.3 — Plugin SPI interfaces |
| #8 | 2026-05-22 | feat(config): Phase 0 Step 0.4 — rag.yaml schema & loader |
| #9 | 2026-05-22 | feat(ci): Phase 0 Step 0.5 — CI/CD pipeline hardening |
| #10–#24 | 2026-05-22 | Dependabot dependency bumps — GitHub Actions / npm / pip (15 PRs) |
| #25 | 2026-05-23 | chore: release main |
| #26 | 2026-05-23 | feat(dev): Phase 0 Step 0.6 — local dev stack |
| #27 | 2026-05-23 | feat(core): OTel tracing + SPI metrics foundation (Step 0.7) |
| #28 | Open | chore: release main |
| #29 | 2026-05-23 | feat(core): structured logging foundation (Step 0.7b) |
| #30 | 2026-05-23 | refactor(observability): extract packages/observability with full log schema (Step 0.7b follow-up) |
| #31 | 2026-05-23 | feat(core): audit log skeleton — AuditStore SPI + hash-chain noop + AuditWriter (Step 0.7c) |
| #32 | 2026-05-23 | docs: add CLAUDE.md with project context and per-PR documentation requirement |
| #33 | 2026-05-23 | feat(eval): eval skeleton — golden-set schema, metrics, RAGAS spike, ragctl eval (Step 0.8) |
| #34 | 2026-05-23 | feat(infra): IaC foundation — Terraform modules + Helm chart skeleton (Step 0.9) |
| #35 | 2026-05-23 | chore(tracker): sync PR links for steps 0.7b–0.9 and history #28–#34 |
| #36 | 2026-05-23 | feat(ragctl): consolidated control-plane CLI scaffold (Step 0.10) |
| #37 | 2026-05-23 | fix(logging): allowlist rag_core/audit.py for RAG001 |
| #38 | 2026-05-23 | ci: wire RAG001 logging check into ci.yml on all OSes |
| #39 | 2026-05-23 | chore(tracker): backfill PR links for #35, #36, #38 |
| #40 | 2026-05-23 | feat(backends): storage backend plugins — pgvector, Qdrant, Redis, S3 (Step 1.1) |
| #41 | 2026-05-23 | docs(planning): Phase 1 architecture-refactor window (Steps 1.1a–1.1f) + ADRs 0005–0009 |
| #42 | 2026-05-23 | chore(tracker): log PR #41 in history table |
| #43 | 2026-05-23 | chore(claude): add /nxt slash command to report next tracker item |
| #44 | 2026-05-23 | feat(core): RequestContext + ctx-threaded SPI (Step 1.1a) |
| #45 | 2026-05-24 | feat(core): SPI split — RetrievalBackend / IndexBackend (Step 1.1b) |
| #46 | 2026-05-24 | feat(policy): rag-policy package — PolicyEngine PDP + coverage linter (Step 1.1c) |
| #47 | 2026-05-24 | chore(schemas): track generated JSON Schemas + add drift CI gate |
| #48 | 2026-05-24 | chore(ci): lint scripts/ + ignore S603/S607 in scripts/** |
| #49 | 2026-05-24 | chore(tracker): sync PR links for steps 1.1a/1.1c + log #46–#48 |
| #50 | 2026-05-24 | feat(core): Pipeline + Batcher primitives (Step 1.1d) |
| #51 | 2026-05-24 | feat(core,observability): Cache SPI split + async telemetry (Step 1.1e) |
| #52 | 2026-05-24 | docs(adr,architecture): finalize ADRs 0005–0009 + extend reviewer checklist (Step 1.1f) |
| #53 | 2026-05-24 | chore(tracker): sync Step 1.1f → ✅ and log PR #52 |
| #54 | 2026-05-24 | chore(tracker): log PR #53 in history |
| #55 | 2026-05-24 | feat(core,backends): connectors framework — SPI evolution + filesystem/S3/GCS (Step 1.2) |
| #56 | 2026-05-24 | chore(tracker): sync Step 1.2 → ✅ and log PR #55 |
| #57 | 2026-05-24 | feat(core,parsers): document parsers — PDF/DOCX/PPTX/XLSX/HTML/MD/text/JSON/CSV/YAML (Step 1.3) |
| #58 | 2026-05-25 | chore(tracker): sync Step 1.3 → ✅ and log PRs #56, #57 |
| #59 | 2026-05-25 | fix(parsers): normalize CRLF/CR to LF in text & markdown parsers |
| #60 | 2026-05-25 | feat(core,ocr): OCR pipeline — region-aware SPI + Tesseract + PaddleOCR (Step 1.4) |
| #61 | 2026-05-25 | chore(tracker): sync Step 1.4 → ✅ and log PRs #58, #59, #60 |
| #62 | 2026-05-25 | feat(core,chunker): structure-aware chunker (Step 1.5) |
| #63 | 2026-05-25 | chore(tracker): sync Step 1.5 → ✅ and log PRs #61, #62 |
| #64 | 2026-05-25 | feat(core,enricher): metadata enricher (Step 1.6) |
| #65 | 2026-05-25 | chore(tracker): sync Step 1.6 → ✅ and log PRs #63, #64 |
| #66 | 2026-05-25 | feat(core,pii): PII detection + per-tenant policy enforcement (Step 1.7) |
| #67 | 2026-05-25 | chore(tracker): sync Step 1.7 → ✅ and log PRs #65, #66 |
| #68 | 2026-05-25 | feat(embedders): embedder pipeline — OpenAI / Cohere / BGE / E5 (Step 1.8) |
| #69 | 2026-05-25 | chore(tracker): sync Step 1.8 → ✅ and log PRs #67, #68; advance to 1.10 |
| #70 | 2026-05-25 | feat(backends): CDC connectors + Document.fingerprint dedup (Step 1.9) |
| #71 | 2026-05-25 | chore(tracker): sync Step 1.9 → ✅ and log PRs #69, #70 |
| #72 | 2026-05-25 | feat(ingest,gateway): write path + POST /v1/ingest (Step 1.10 — CLI demo milestone) |
| #73 | 2026-05-25 | chore(tracker): mark Phase 1 complete (Step 1.10 → ✅; log PRs #71, #72) |
| #74 | 2026-05-25 | feat(core,backends): knowledge store read layer + FilterExpr push-down (Step 2.1) |
| #75 | 2026-05-25 | chore(tracker): sync Step 2.1 → ✅ and log PRs #73, #74 |
| #76 | 2026-05-25 | feat(backends): Weaviate, Pinecone, Elasticsearch vector backends + IndexHint wiring (Step 2.2) |
| #77 | 2026-05-26 | chore(tracker): sync Step 2.2 → log PR #76 + history rows #75, #76 |
| #82 | 2026-05-26 | feat(backends): BM25 keyword retrieval — Elasticsearch + tantivy (Step 2.3) |
| #83 | 2026-05-26 | chore(tracker): sync Step 2.3 → log PR #82 + history rows #77, #82 |
| #84 | 2026-05-26 | feat(core,backends): graph retrieval — Neo4j + Memgraph + NetworkX + expand() (Step 2.4) |
| #85 | 2026-05-26 | chore(tracker): sync Step 2.4 → log PR #84 + history rows #83, #84 |
| #86 | 2026-05-26 | feat(retrieval): hybrid RRF fusion — rrf_fuse + HybridRetriever (Step 2.5) |
| #87 | 2026-05-26 | fix(tooling): unblock mypy by removing duplicate package-local tests/init.py |
| #88 | 2026-05-26 | fix(retrieval): cast policy_filter to restore FilterExpr across lazy boundary |
| #89 | 2026-05-26 | chore(tracker): sync Step 2.5 → ✅ and log PRs #85–#88 |
| #90 | 2026-05-26 | feat(query): rag-query — query understanding (rewriter, decomposer, HyDE, glossary) |
| #91 | 2026-05-26 | chore(tracker): sync Step 2.6 → ✅ and log PR #90 |
| #92 | 2026-05-26 | feat(reranker): rag-reranker — two-stage cross-encoder + MMR (Step 2.7) |
| #93 | 2026-05-26 | chore(tracker): sync Step 2.7 → ✅ and log PR #92 |
| #94 | 2026-05-26 | feat(packer): rag-packer — context packer (dedup + reorder + budget + conflicts) (Step 2.8) |
| #95 | 2026-05-26 | chore(tracker): sync Step 2.8 → ✅ and log PR #94 |
| #96 | 2026-05-26 | feat(graphrag): rag-graphrag — community detection + summarisation + graph-aware retrieval (Step 2.9) |
| #97 | 2026-05-26 | chore(tracker): sync Step 2.9 → ✅ and log PR #96 |
| #98 | 2026-05-26 | feat(retrieval): retrieval router — query-shape classifier + health tracker + BM25-only fallback (Step 2.10) |
| #99 | 2026-05-26 | chore(tracker): sync Step 2.10 → ✅ and log PR #98 |
| #100 | 2026-05-26 | feat(retrieval): agent-loop validation spike — AgentLoopV0 + 50-query harness + gap list (Step 2.11) |
| #101 | 2026-05-26 | refactor(retrieval): split RetrievalRouter.route into decide + execute (G-02) |
| #102 | 2026-05-26 | fix(core): reject zero Budget caps at construction (G-03) |
| #103 | 2026-05-26 | feat(query): cache HyDE embeddings + rewriter outputs (G-06) |
| #104 | 2026-05-26 | feat(retrieval): semantic embedding cache + ADR-0010 (G-01) |
| #105 | 2026-05-26 | feat(retrieval): per-stage cost estimator + ADR-0008 backlink (G-04) |
| #106 | 2026-05-26 | chore(tracker): sync Phase 2 + pre-3.1 fixes complete; log PRs #101–#105 |
| #107 | 2026-05-27 | feat(gateway): Curl-able RAG — /v1/query + /v1/retrieve + /v1/corpora (Step 3.1) |
| #108 | 2026-05-27 | chore(tracker): sync PR #107 link for Step 3.1 |
| #109 | 2026-05-27 | feat(gateway): gRPC service — RagService over rag.gateway.v1 (Step 3.2) |
| #110 | 2026-05-27 | chore(tracker): sync PR #109 link for Step 3.2 |
| #111 | 2026-06-01 | feat(gateway): MCP server — query/retrieve/ingest tools (Step 3.3) |
| #112 | 2026-06-01 | chore(tracker): sync PR #111 link for Step 3.3 |
| #113 | 2026-06-01 | feat(gateway): OpenAI-compatible endpoints with retrieval pre-fetch (Step 3.4) |
| #114 | 2026-06-01 | chore(tracker): sync PR #113 link for Step 3.4 |
| #115 | 2026-06-03 | feat(retrieval): corpus router with static rules, learned classifier & federated fan-out (Step 3.5) |
| #119 | 2026-06-03 | chore(tracker): sync PR #115 link for Step 3.5 |
| #120 | 2026-06-04 | feat(agent): production agent runtime over REST SSE + gRPC Converse (Step 3.6) |
| #121 | 2026-06-04 | feat(sdks): official Python/TypeScript/Go/Java/.NET SDKs (Step 3.7) |
| #122 | 2026-06-04 | feat(integrations): framework adapters for 8 agent/RAG frameworks (Step 3.8) |
| #123 | 2026-06-04 | feat(webhooks): signed outbound webhook delivery (Step 3.9) |
| #124 | 2026-06-04 | feat(admin-ui): Next.js 14 operator console (Step 3.10) |
| #125 | 2026-06-04 | feat(gateway): status & metrics surface + operator console pages (Step 3.11) |
| #126 | 2026-06-04 | feat(cache): tiered semantic cache L0/L1/L2 (Step 4.1) |
| #127 | 2026-06-04 | feat(retrieval): fallback chain (Step 4.2) |
| #128 | 2026-06-04 | feat(guard): hallucination guard (Step 4.3) |
| #129 | 2026-06-04 | feat(breaker): per-backend circuit breakers (Step 4.4) |
| #130 | 2026-06-04 | feat(quota): per-tenant quotas & rate limiting (Step 4.5) |
| #131 | 2026-06-04 | feat(perf): gateway latency gate, load test & profiling (Step 4.6) |
| #132 | 2026-06-04 | feat(provenance): per-query tracing & HMAC-signed provenance (Step 5.1) |
| #133 | 2026-06-05 | feat(eval): offline golden-set eval harness (Step 5.2) |
| #134 | 2026-06-05 | feat(eval): CI eval gate (Step 5.3) |
| #135 | 2026-06-05 | feat(feedback): online metrics & feedback (Step 5.4) |
| #136 | 2026-06-05 | feat(drift): drift monitors (Step 5.5) |
| #137 | 2026-06-05 | feat(admin-ui): drift & feedback Live Status cards (Step 5.6a) |
| #138 | 2026-06-05 | feat(admin-ui): query trace & provenance viewer (Step 5.6b) |
| #139 | 2026-06-05 | feat(cost): per-tenant cost-anomaly detection + console card (Step 5.6c) |
| #140 | 2026-06-05 | feat(eval): regression bisector over git history (Step 5.6d) |
| #141 | 2026-06-05 | feat(observability): drift + cost Prometheus metrics + Grafana dashboard (Step 5.6e) |
| #142 | 2026-06-05 | feat(admin-ui): observability cross-links + Step 5.6 close-out (5.6f) |
| #143 | 2026-06-05 | feat(experiments): A/B analyzer + tracker + dashboard (Step 5.7a) |
| #144 | 2026-06-05 | docs(tracker): restructure for readability + complete PR history |
| #145 | 2026-06-05 | feat(experiments): shadow mode — observe-only candidate fan-out (Step 5.7b) |
| #146 | 2026-06-07 | feat(experiments): A/B routing — serve the candidate to a fraction of users (Step 5.7c) |
| #147 | 2026-06-07 | feat(admin-ui): A/B experiments console card + Phase-5 close-out (Step 5.7d) |
| #148 | 2026-06-08 | feat(tenancy): logical multi-tenancy — per-tenant config resolution (Step 6.1) |
| #149 | 2026-06-08 | feat(tenancy): physical tenancy — dedicated index per tenant (Step 6.2) |
| #150 | 2026-06-08 | feat(policy): ACL push-down at retrieval — AclPolicyEngine (Step 6.3) |
| #151 | 2026-06-08 | feat(policy): ACL egress verifier — defense-in-depth re-check (Step 6.4) |
| #152 | 2026-06-08 | feat(pii): PII egress policies — PiiPolicyEngine egress_text decorator (Step 6.5) |
| #153 | 2026-06-08 | feat(gateway): audit read API + chain verification (Step 6.6a) |
| #154 | 2026-06-08 | feat(audit): WORM signed export — AuditExporter + POST /v1/audit/export (Step 6.6b) |
| #155 | 2026-06-08 | feat(crypto): BYOK envelope encryption library + local KMS (Step 6.7a) |
| #156 | 2026-06-08 | feat(crypto): cfg.kms + key-manager factory + AWS KMS provider (Step 6.7b) |
| #157 | 2026-06-08 | feat(crypto): GCP / Azure / Vault KMS providers (Step 6.7c) |
| #158 | 2026-06-08 | feat(crypto): zero-downtime key rotation — RotatingKeyManager (Step 6.7d) |
| #165 | 2026-06-08 | test(perf): chaos kill-matrix gate + cache/rerank degrade-open + LitmusChaos (Step 7.2) |
| #166 | 2026-06-08 | feat(security): prompt-injection guard (rag-injection) + red-team probe gate (Step 7.3) |
| #174 | 2026-06-10 | feat(pilot): design-partner pilot program — framework + customer-support kit + ragctl pilot + case study (Step 7.4) |
| #175 | 2026-06-10 | fix(core): carry corpus_id on Embedding for vector corpus scoping (ADR-0004 §3) |
| #176 | 2026-06-10 | fix(gateway): scope answers to the question's subject on both answer prompts |
| #177 | 2026-06-10 | feat(admin-ui): wire dashboard + audit page to live data; clearer trace help |
| #178 | 2026-06-10 | feat(docs): Docusaurus documentation site + generated API reference + doc-tests (Step 7.5) |
| #179 | 2026-06-10 | docs(marketplace): AWS/Azure/GCP listing artifacts + canonical pricing model (Step 7.6) |
| #180 | 2026-06-10 | feat(release): rag-platform PyPI meta + release.yml (PyPI/npm/Helm-OCI/admin-ui image) (Step 7.7) |
| #181 | 2026-06-10 | docs(support): support tiers + SLA + per-alert runbooks + incident response (Step 7.8) |
| #182 | 2026-06-10 | feat(billing): per-tenant usage metering + usage API + invoice generation (Step 7.9) |
| #183 | 2026-06-10 | chore(release): GA cutover — v1.0.0 release notes + readiness checklist + SemVer policy (Step 7.10) |
| #184 | 2026-06-10 | docs: GA polish — site publishing scope, HLD v1.2, ragctl reference, port + link fixes |
| #185 | 2026-06-10 | fix(gateway): v1.0.1 — container/Helm serve path: config-driven entrypoint + /readyz + valid chart config |
| #189 | 2026-06-11 | feat(gateway,admin-ui): corpus create/delete admin CRUD (Step 3.10) |
| #78–#80, #116–#118 | Open | Dependabot bumps — awaiting merge |
| #81 | Closed | Dependabot bump — superseded |
build/phase-{N}/step-{N}.{M}-{short-slug}
Examples:
build/phase-0/step-0.2-core-typesbuild/phase-1/step-1.5-chunkerbuild/phase-3/step-3.1-gateway-rest