mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
revisão e complementação da documentação do projeto
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: RI CI
|
||||
|
||||
# EP-00-T03 (docs/RI-PLANNING.md): lint, type-check, validate every config/
|
||||
# EP-00-T03: lint, type-check, validate every config/
|
||||
# YAML against its JSON Schema/Pydantic model, then run the test suite.
|
||||
# Acceptance criterion: a YAML under config/ that violates its schema must
|
||||
# fail CI before any integration test runs -- `validate-schemas` is
|
||||
|
||||
3266
README.en-us.md
Normal file
3266
README.en-us.md
Normal file
File diff suppressed because it is too large
Load Diff
20
README.md
20
README.md
@@ -7,7 +7,7 @@
|
||||
|
||||
## Reference implementation
|
||||
|
||||
A implementação executável desta arquitetura está planejada para [`RI/`](RI/README.md) — diretório a ser criado a partir do backlog estruturado em [`docs/RI-PLANNING.md`](docs/RI-PLANNING.md) — com gateway MCP, PEP/PDP sobre OPA (Rego v1), Keycloak como Identity Provider (OAuth 2.1/OIDC), serviços de exemplo, testes adversariais e ambiente Docker Compose.
|
||||
A implementação executável desta arquitetura está concluída em [`RI/`](RI/README.md) — com gateway MCP, PEP/PDP sobre OPA (Rego v1), Keycloak como Identity Provider (OAuth 2.1/OIDC), serviços de exemplo, testes adversariais e ambiente Docker Compose. Ver [`RI/docs/AS-BUILT.md`](RI/docs/AS-BUILT.md) para a referência técnica do que foi implementado e validado, e [`docs/adr/`](docs/adr/) para as decisões arquiteturais registradas.
|
||||
|
||||
> **Nota de implementação (v0.2+).** A camada de orquestração de agentes (LangGraph) e de observabilidade de LLM (Langfuse) da RI adota como referência operacional o projeto open-source *Agent Platform OCI*, de Christiano Hoshikawa — ver §6.2, §6.12, §35.1 e §43.5 para as anotações correspondentes, e `docs/research/hoshikawa-agent-platform-oci.md` para a análise completa.
|
||||
|
||||
@@ -2672,20 +2672,20 @@ A visão arquitetural está consistente, mas uma implementação concreta precis
|
||||
- workload identity?
|
||||
- mTLS?
|
||||
- cloud IAM?
|
||||
- → **Decisão adotada na RI**: OAuth 2.1 `client_credentials` via Keycloak, com mTLS/workload identity documentados como extensão de produção não implementada (`docs/RI-PLANNING.md`, ADR-019).
|
||||
- → **Decisão adotada na RI**: OAuth 2.1 `client_credentials` via Keycloak, com mTLS/workload identity documentados como extensão de produção não implementada (`docs/adr/ADR-019-identity-extension.md`).
|
||||
|
||||
2. **Qual será a granularidade dos client profiles?**
|
||||
- domínio?
|
||||
- domínio + read/write?
|
||||
- domínio + risk tier?
|
||||
- → **Decisão adotada na RI**: domínio + risk tier (ver §17; `docs/RI-PLANNING.md`, ADR-008).
|
||||
- → **Decisão adotada na RI**: domínio + risk tier (ver §17; `docs/adr/ADR-008-segmentacao-por-dominio-e-risco.md`).
|
||||
|
||||
3. **Qual engine implementará o PDP?**
|
||||
- engine próprio?
|
||||
- Cedar?
|
||||
- OPA/Rego?
|
||||
- IAM/policy service existente?
|
||||
- → **Decisão adotada na RI**: OPA/Rego (sintaxe v1), self-hosted (`docs/RI-PLANNING.md`, ADR-025 — não confundir com o ADR-010 acima, "Global Capability Registry Governance", que é uma decisão distinta).
|
||||
- → **Decisão adotada na RI**: OPA/Rego (sintaxe v1), self-hosted (`docs/adr/ADR-025-engine-do-pdp.md` — não confundir com o ADR-010 acima, "Global Capability Registry Governance", que é uma decisão distinta).
|
||||
|
||||
4. **Quem é owner do Global Capability Registry?**
|
||||
|
||||
@@ -2693,21 +2693,21 @@ A visão arquitetural está consistente, mas uma implementação concreta precis
|
||||
- workload identity?
|
||||
- OBO/token exchange?
|
||||
- backend service account?
|
||||
- → **Decisão adotada na RI**: token exchange (RFC 8693) quando o backend suportar OIDC, senão service account isolado por adapter (`docs/RI-PLANNING.md`, ADR-020).
|
||||
- → **Decisão adotada na RI**: token exchange (RFC 8693) quando o backend suportar OIDC, senão service account isolado por adapter (`docs/adr/ADR-020-downstream-identity.md`).
|
||||
|
||||
6. **Quais tools exigirão human approval?**
|
||||
|
||||
7. **Como policy revocation invalidará caches?**
|
||||
- → **Decisão adotada na RI**: invalidação disparada por evento de revogação/mudança de política, com TTL curto como rede de segurança adicional (`docs/RI-PLANNING.md`, EP-04-T03/EP-05-T03).
|
||||
- → **Decisão adotada na RI**: invalidação disparada por evento de revogação/mudança de política, com TTL curto como rede de segurança adicional (`RI/docs/AS-BUILT.md`, componentes Entitlement Manager e Gateway).
|
||||
|
||||
8. **Qual será o modelo de federation entre domain MCP servers?**
|
||||
- → **Escopo da RI**: federação completa fica fora de escopo — a RI demonstra 2 domínios (Sales, Finance), suficiente para provar segmentação/blast-radius (`docs/RI-PLANNING.md`, lacuna G9).
|
||||
- → **Escopo da RI**: federação completa fica fora de escopo — a RI demonstra 2 domínios (Sales, Finance), suficiente para provar segmentação/blast-radius (`RI/docs/Assumptions.md`).
|
||||
|
||||
9. **Como impedir bypass em ambientes híbridos/multi-cloud?**
|
||||
- → **Escopo da RI**: a RI roda 100% local via Docker Compose; ambientes híbridos/multi-cloud ficam fora de escopo (`docs/RI-PLANNING.md`, lacuna G10).
|
||||
- → **Escopo da RI**: a RI roda 100% local via Docker Compose; ambientes híbridos/multi-cloud ficam fora de escopo (`RI/docs/Assumptions.md`).
|
||||
|
||||
10. **Como separar dev/test/prod client identities e entitlements?**
|
||||
- → **Decisão adotada na RI**: overlay de configuração por ambiente (`config/env/{dev,ci}.yaml`), sem infraestrutura cloud real (`docs/RI-PLANNING.md`, P10).
|
||||
- → **Decisão adotada na RI**: overlay de configuração por ambiente (`config/env/{dev,ci}.yaml`), sem infraestrutura cloud real (`RI/docs/Assumptions.md`).
|
||||
|
||||
---
|
||||
|
||||
@@ -2735,7 +2735,7 @@ ADR-016: Agent Runtime Dispatch and Handoff Contract
|
||||
ADR-017: End-to-End Decision and Execution Correlation
|
||||
```
|
||||
|
||||
> **Nota de implementação.** A RI estende esta lista com ADR-018 em diante — decisões de tecnologia concreta (stack Python/YAML, engine do PDP, Identity Provider, downstream identity, referência de implementação Python/LangGraph/Langfuse, posicionamento frente a plataformas de orquestração externas) necessárias para transformar estes 17 ADRs conceituais em uma implementação executável. Ver `docs/RI-PLANNING.md`, seção 8.4, para a lista completa (ADR-001 a ADR-025) e `docs/research/hoshikawa-agent-platform-oci.md` para a pesquisa que fundamenta ADR-022 a ADR-024.
|
||||
> **Nota de implementação.** A RI estende esta lista com ADR-018 em diante — decisões de tecnologia concreta (stack Python/YAML, engine do PDP, Identity Provider, downstream identity, referência de implementação Python/LangGraph/Langfuse, posicionamento frente a plataformas de orquestração externas) necessárias para transformar estes 17 ADRs conceituais em uma implementação executável. Ver [`docs/adr/`](docs/adr/) para a lista completa (ADR-001 a ADR-025) e `docs/research/hoshikawa-agent-platform-oci.md` para a pesquisa que fundamenta ADR-022 a ADR-024.
|
||||
|
||||
---
|
||||
|
||||
|
||||
128
RI/README.en-us.md
Normal file
128
RI/README.en-us.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Enterprise MCP Service Bus — Reference Implementation
|
||||
|
||||
Executable implementation of the architecture described in [`../README.md`](../README.md)
|
||||
(reference architecture). For the complete technical reference of what exists and how it works,
|
||||
see [`docs/AS-BUILT.md`](docs/AS-BUILT.md).
|
||||
|
||||
## Status
|
||||
|
||||
The entire planned backlog (milestones M0 through M4) is implemented, tested, and validated.
|
||||
**261 automated tests**: 254 pass with `pytest --forked` (unit + e2e + contract + adversarial),
|
||||
plus 7 security acceptance tests (`tests/e2e/test_security_acceptance.py`) that require real
|
||||
Docker and are run separately due to the build+Keycloak cost per run. Production logic with no
|
||||
mocks in the security and orchestration core: real OPA (subprocess), real Keycloak (Docker),
|
||||
real LangGraph with a SQLite checkpointer, a real LLM (OpenAI-compatible endpoint), real HTTP
|
||||
JWKS with real RS256 JWTs, real YAML from `config/`, real SQLite for the Registry.
|
||||
|
||||
## Navigation
|
||||
|
||||
| Document | For whom |
|
||||
|---|---|
|
||||
| [`docs/AS-BUILT.md`](docs/AS-BUILT.md) | Integrating with or extending the RI — what exists, how it works, what was validated. |
|
||||
| [`docs/How-To.md`](docs/How-To.md) | Running the RI — installation, tests, local stack, publishing a capability, registering a client. |
|
||||
| [`docs/interfaces/`](docs/interfaces/) | Exact contracts per interface (Gateway, identity, PDP, Registry, downstream identity, auditing, approval, agent runtime). |
|
||||
| [`docs/Assumptions.md`](docs/Assumptions.md) | Evaluating/adapting the RI — what was assumed and why. |
|
||||
| [`docs/Production-Recommendations.md`](docs/Production-Recommendations.md) | Evaluating/adapting the RI — what changes for real production. |
|
||||
| [`../docs/adr/`](../docs/adr/) | Individual architectural decisions (ADR-001 through ADR-025). |
|
||||
|
||||
## Quickstart (without Docker)
|
||||
|
||||
```bash
|
||||
make install # creates .venv and installs the package in editable mode (dev extras included)
|
||||
make lint # ruff check + ruff format --check
|
||||
make typecheck # mypy --strict
|
||||
make validate-schemas # validates all YAML under config/ against its Pydantic model
|
||||
make test # pytest --forked (unit + e2e + contract + adversarial)
|
||||
```
|
||||
|
||||
`make test` needs the `opa` binary on the PATH (or `OPA_BINARY`) for the tests that exercise the
|
||||
real PDP — without it, those tests are skipped (`pytest.skip`), not failed. `docker` on the PATH
|
||||
is required for the same reason for `tests/e2e/test_real_keycloak.py`. See
|
||||
[`docs/How-To.md`](docs/How-To.md) for the complete step-by-step, including how to install OPA
|
||||
without Docker and bring up the services directly on the host.
|
||||
|
||||
## Quickstart (Docker)
|
||||
|
||||
```bash
|
||||
make up # == docker compose --profile core up --build (without IdP)
|
||||
make down
|
||||
make logs
|
||||
|
||||
# With a real Keycloak:
|
||||
cp .env.example .env # uncomment the 3 OIDC_* lines
|
||||
docker compose --profile core --profile identity up --build
|
||||
```
|
||||
|
||||
See [`docs/How-To.md`](docs/How-To.md#bringing-up-the-local-stack) for the available profiles
|
||||
(`core`, `identity`, `observability`) and what each one adds.
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
RI/
|
||||
├── pyproject.toml
|
||||
├── Makefile
|
||||
├── docker-compose.yml # profiles: core (gateway+sales-domain+finance-domain+opa),
|
||||
│ # identity (+keycloak), observability (+otel-collector+jaeger)
|
||||
├── .env.example
|
||||
├── deploy/
|
||||
│ ├── docker/Dockerfile
|
||||
│ ├── keycloak/realm-export.json # realm "emcp", 3 clients
|
||||
│ ├── opa/
|
||||
│ ├── otel/collector-config.yaml
|
||||
│ └── langfuse/docker-compose.override.yml # llm-observability profile, opt-in
|
||||
├── scripts/{export_schemas,validate_config}.py
|
||||
├── schemas/*.schema.json # generated by export_schemas.py
|
||||
├── config/
|
||||
│ ├── env/{dev,ci}.yaml
|
||||
│ ├── policies/ # entitlement.rego, transaction.rego
|
||||
│ ├── clients/{*.yaml,profiles/*.yaml} # registrations + client profiles
|
||||
│ ├── backends/*.yaml # endpoint + outbound identity per backend
|
||||
│ ├── capabilities/{sales,finance}/*.yaml
|
||||
│ └── orchestrator/identity.yaml
|
||||
├── src/emcp_bus/
|
||||
│ ├── common/{config,otel,models,pii_redaction}.py
|
||||
│ ├── identity/{models,authn,token_verifier,shared_client_lint}.py
|
||||
│ ├── entitlement/{models,manager}.py
|
||||
│ ├── registry/{models,store,pipeline,lifecycle,seed}.py
|
||||
│ ├── pdp/{client,models}.py
|
||||
│ ├── fabric/{router,adapters/rest_adapter}.py
|
||||
│ ├── downstream/{models,identity}.py
|
||||
│ ├── audit/{models,events,sink,correlation,bypass_detection}.py
|
||||
│ ├── approval/{models,service}.py
|
||||
│ ├── gateway/{server,cache,canonical_request}.py
|
||||
│ ├── profile_intelligence/{models,rule_based_provider,governance}.py
|
||||
│ ├── offering_filter/service.py
|
||||
│ ├── orchestrator/{decision_context,state,graph,nba_model,decisioning,fallback,hitl_node}.py
|
||||
│ └── agent_runtime/{dispatcher,identity_resolver,handoff_graph,global_supervisor,channels/*}.py
|
||||
├── services/example_mcp_servers/
|
||||
│ ├── sales_domain/server.py # 6 tools: R1 + R2
|
||||
│ └── finance_domain/server.py # invoice.get R1, payment.create R2, payment.execute R3
|
||||
├── agents/example_agent/
|
||||
│ ├── agent.py, llm_client.py
|
||||
│ ├── prompts/{store.py,v1.txt}
|
||||
│ ├── backends/{sales_agent,finance_agent}/ # Mode A (ADR-024)
|
||||
│ └── mode_b_orchestrator_driven/{decision_model,orchestrator}.py # Mode B (ADR-024)
|
||||
├── docs/
|
||||
│ ├── AS-BUILT.md
|
||||
│ ├── How-To.md
|
||||
│ ├── Assumptions.md
|
||||
│ ├── Production-Recommendations.md
|
||||
│ └── interfaces/*.md
|
||||
└── tests/
|
||||
├── unit/
|
||||
├── e2e/ # walking skeleton, governed gateway, real Keycloak, example agent,
|
||||
│ # security acceptance, multi-agent Mode A, fail-closed
|
||||
├── contract/ # input/output contract of each domain server
|
||||
└── adversarial/ # prompt/tool injection, protocol/identity
|
||||
```
|
||||
|
||||
## Pinned Versions
|
||||
|
||||
See `pyproject.toml`. Main ones: `mcp` 2.2.0, `pydantic` 2.13.x, `opentelemetry-sdk` 1.44.x,
|
||||
`uvicorn` 0.52.x, `pyjwt[crypto]` 2.14.x, `ruff` 0.16.x, `mypy` 2.3.x, `pytest` 9.1.x,
|
||||
`pytest-forked` 1.7.x, `langgraph` 0.6.x + `langgraph-checkpoint-sqlite` 2.x, `hypothesis`
|
||||
6.1xx.x (dev), `openai` 1.109.x, `python-dotenv` 1.2.x (dev), OPA v1.20.2 (static binary, Rego
|
||||
v1 default), Keycloak 26.7.3, Langfuse self-hosted 4.35.0, `opentelemetry-collector-contrib`
|
||||
0.114.0, `jaeger` (all-in-one) 1.62.0. Full record with provenance for each version in
|
||||
`docs/adr/ADR-018-stack-de-implementacao.md`.
|
||||
319
RI/README.md
319
RI/README.md
@@ -1,8 +1,29 @@
|
||||
# Enterprise MCP Service Bus — Reference Implementation
|
||||
|
||||
Implementação executável da arquitetura descrita em [`../README.md`](../README.md), planejada em [`../docs/RI-PLANNING.md`](../docs/RI-PLANNING.md).
|
||||
Implementação executável da arquitetura descrita em [`../README.md`](../README.md) (arquitetura
|
||||
de referência). Para a referência técnica completa do que existe e como funciona, ver
|
||||
[`docs/AS-BUILT.md`](docs/AS-BUILT.md).
|
||||
|
||||
> **Status: M0, M1, M2, M3 e M4 concluídos — backlog completo, nenhuma tarefa aberta** (M0-M2 implementados 2026-09-11 e revalidados 2026-09-14; M3 implementado 2026-09-14; M4 implementado e concluído 2026-09-14, incluindo as duas tarefas `could` EP-13-T05/T06). **260 testes automatizados** (253 passando com `--forked` — unit + e2e + contract + adversarial, sem flakiness — mais 7 testes de aceite de segurança contra o docker-compose real, EP-15-T02, rodados separadamente pelo custo de build+Keycloak por execução). M3 (Profile Intelligence, Offering Filter, Service Orchestrator com LangGraph real, Agent Runtime, agente de exemplo com LLM real + Langfuse self-hosted real) está descrito na seção "M3 — Personalização e orquestração"; M4 (testes de contrato, suíte de aceite de segurança, testes adversariais, testes de fail-closed, os dois modos multiagente do ADR-024, ADRs formalizados) na seção "M4 — Hardening e entrega", ambas abaixo. Detalhes completos e critérios de aceite verificados em `docs/RI-PLANNING.md` seção 8.7.
|
||||
## Status
|
||||
|
||||
Todo o backlog planejado (marcos M0 a M4) está implementado, testado e validado. **261 testes
|
||||
automatizados**: 254 passam com `pytest --forked` (unit + e2e + contract + adversarial), mais 7
|
||||
testes de aceite de segurança (`tests/e2e/test_security_acceptance.py`) que exigem Docker real e
|
||||
são rodados separadamente pelo custo de build+Keycloak por execução. Lógica de produção sem
|
||||
mocks no núcleo de segurança e orquestração: OPA real (subprocess), Keycloak real (Docker),
|
||||
LangGraph real com checkpointer SQLite, um LLM real (endpoint OpenAI-compatible), JWKS HTTP real
|
||||
com JWT RS256 reais, YAML reais de `config/`, SQLite real para o Registry.
|
||||
|
||||
## Navegação
|
||||
|
||||
| Documento | Para quem |
|
||||
|---|---|
|
||||
| [`docs/AS-BUILT.md`](docs/AS-BUILT.md) | Integrar com ou estender a RI — o que existe, como funciona, o que foi validado. |
|
||||
| [`docs/How-To.md`](docs/How-To.md) | Rodar a RI — instalação, testes, stack local, publicar uma capability, registrar um client. |
|
||||
| [`docs/interfaces/`](docs/interfaces/) | Contratos exatos por interface (Gateway, identidade, PDP, Registry, downstream identity, auditoria, aprovação, agent runtime). |
|
||||
| [`docs/Assumptions.md`](docs/Assumptions.md) | Avaliar/adaptar a RI — o que foi assumido e por quê. |
|
||||
| [`docs/Production-Recommendations.md`](docs/Production-Recommendations.md) | Avaliar/adaptar a RI — o que muda para produção real. |
|
||||
| [`../docs/adr/`](../docs/adr/) | Decisões arquiteturais individuais (ADR-001 a ADR-025). |
|
||||
|
||||
## Quickstart (sem Docker)
|
||||
|
||||
@@ -11,257 +32,97 @@ make install # cria .venv e instala o pacote em modo editável (dev extr
|
||||
make lint # ruff check + ruff format --check
|
||||
make typecheck # mypy --strict
|
||||
make validate-schemas # valida todo YAML de config/ contra seu modelo Pydantic
|
||||
make export-schemas # regenera schemas/*.schema.json a partir dos modelos
|
||||
make test # pytest --forked (unit + e2e) -- ver nota abaixo
|
||||
make test # pytest --forked (unit + e2e + contract + adversarial)
|
||||
```
|
||||
|
||||
`make test` precisa do binário `opa` no PATH (ou `OPA_BINARY` apontando para ele) para os testes que exercitam o PDP de verdade — ver "Como instalar o OPA" abaixo. Sem ele, esses testes são pulados (`pytest.skip`), não falham. Os testes de `test_real_keycloak.py` precisam de `docker` no PATH pela mesma razão.
|
||||
|
||||
### Flakiness da suíte e2e: resolvida com `--forked`
|
||||
|
||||
Sessões anteriores documentaram falhas intermitentes (`mcp.shared.exceptions.MCPError: SSE stream ended without a response`) ao rodar `pytest tests/e2e/` sem isolamento de processo — a hipótese era limpeza assíncrona (anyio/httpx/uvicorn) compartilhada entre pares de servidor ASGI em processo, na mesma sessão pytest, e várias mitigações (mais retries, pausas, isolar por arquivo) não resolviam. O follow-up então recomendado — isolar cada teste em subprocesso próprio via `pytest-forked` — foi implementado e **confirma a hipótese**: com `--forked` (um processo `fork()` por função de teste), a suíte inteira passa de forma consistente. Verificado nesta sessão: **119/119 testes (97 unit + 22 e2e) passando em múltiplas execuções seguidas**, contra a mesma taxa de 1-9 falhas por execução sem `--forked`. `make test` e `.github/workflows/ci.yml` já usam `--forked` por padrão; o custo é só tempo de execução (~+1min nesta máquina).
|
||||
|
||||
### Como instalar o OPA (sem Docker)
|
||||
|
||||
OPA é um binário Go estático — não precisa de Docker nem de build:
|
||||
|
||||
```bash
|
||||
curl -sL -o /tmp/opa "https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static"
|
||||
chmod +x /tmp/opa
|
||||
cp /tmp/opa .venv/bin/opa # coloca no PATH do venv
|
||||
opa version # confirma
|
||||
```
|
||||
|
||||
Versão usada durante o desenvolvimento desta RI: **OPA v1.20.2** (Rego v1 é o padrão — as policies em `config/policies/*.rego` usam `import rego.v1`).
|
||||
|
||||
### Subir a stack completa localmente (sem Docker)
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
opa run --server --addr 127.0.0.1:8181 config/policies/ &
|
||||
export SALES_DOMAIN_SERVICE_TOKEN=dev-sales-domain-service-token # EP-07: credencial de saída, nunca o token do client
|
||||
export FINANCE_DOMAIN_SERVICE_TOKEN=dev-finance-domain-service-token
|
||||
python3 -m example_mcp_servers.sales_domain.server &
|
||||
python3 -m example_mcp_servers.finance_domain.server &
|
||||
# Sem OIDC_JWKS_URL/OIDC_ISSUER, o Gateway roda em modo "sem IdP configurado":
|
||||
# nega tudo por padrão (ver "Dois modos de deployment" abaixo).
|
||||
python3 -m emcp_bus.gateway.server
|
||||
```
|
||||
|
||||
O Fabric router (EP-06-T01) resolve cada capability para o backend correto via `config/backends/*.yaml` + o Registry (seedado de `config/capabilities/` no startup do Gateway) — não há mais uma única `SALES_DOMAIN_URL` estática cobrindo tudo.
|
||||
|
||||
Para autenticação real, aponte `OIDC_JWKS_URL`/`OIDC_ISSUER` para um Keycloak real (EP-01-T01, ✅ implementado e validado — ver `deploy/keycloak/realm-export.json` e `tests/e2e/test_real_keycloak.py`) ou qualquer IdP OIDC compliant (`TokenValidator` é agnóstico de fornecedor). A maior parte da suíte (`tests/e2e/conftest.py`) continua usando um servidor JWKS local mais leve como stand-in — válido para a lógica de validação, mas `test_real_keycloak.py` é quem prova a integração com Keycloak de verdade (client_credentials grant, claims reais, `iss`/`aud` reais).
|
||||
`make test` precisa do binário `opa` no PATH (ou `OPA_BINARY`) para os testes que exercitam o
|
||||
PDP real — sem ele, esses testes são pulados (`pytest.skip`), não falham. `docker` no PATH é
|
||||
necessário pela mesma razão para `tests/e2e/test_real_keycloak.py`. Ver
|
||||
[`docs/How-To.md`](docs/How-To.md) para o passo a passo completo, incluindo como instalar o OPA
|
||||
sem Docker e subir os serviços diretamente no host.
|
||||
|
||||
## Quickstart (Docker)
|
||||
|
||||
```bash
|
||||
make up # == docker compose --profile core up --build (sem IdP)
|
||||
make down # == docker compose --profile core down
|
||||
make logs # == docker compose --profile core logs -f
|
||||
make up # == docker compose --profile core up --build (sem IdP)
|
||||
make down
|
||||
make logs
|
||||
|
||||
# Com Keycloak real (EP-01-T01):
|
||||
# Com Keycloak real:
|
||||
cp .env.example .env # descomente as 3 linhas OIDC_*
|
||||
docker compose --profile core --profile identity up --build
|
||||
```
|
||||
|
||||
> **✅ Validado com um daemon Docker real** (Docker 29.8.0 / Compose v5.5.1) — `gateway` + `sales-domain` + `opa` (+ `keycloak`, opcional) sobem, o Gateway sincroniza entitlements reais com o OPA real do compose, e um cliente MCP real do host completa `tools/list`/`tools/call` através do Gateway containerizado, com ou sem Keycloak. Ver "O que foi validado com Docker" abaixo para o que exatamente foi exercitado (incluindo três bugs reais encontrados e corrigidos ao longo dessas rodadas). Sem `.env`/perfil `identity`, o Gateway do compose roda em modo "sem IdP" (toda requisição não autenticada, deny-by-default) — os dois modos são deliberados, não um "modo incompleto" esperando o outro.
|
||||
|
||||
### O que foi validado com Docker
|
||||
|
||||
Build + up + smoke test end-to-end, mais dois testes adicionais montando um segundo container `gateway` manualmente (`docker run --network ri_default ...`) para exercitar caminhos que o compose padrão (sem Keycloak) não cobre, e (nesta rodada) uma segunda validação completa após o Fabric router multi-backend (EP-06-T01), o domínio Finance real (EP-06-T03), a segmentação de rede (EP-05-T07) e a stack de observabilidade (EP-08-T04):
|
||||
|
||||
1. **`docker compose --profile core up --build`** sobe os 4 serviços (`opa`, `sales-domain`, `finance-domain`, `gateway`); ambos os domain servers ficam `healthy`; `gateway` sincroniza com `opa` e seeda o Registry a partir de `config/capabilities/` no startup, permanecendo de pé.
|
||||
2. **Modo sem OIDC** (compose padrão): `tools/list` vazio, `tools/call` negado com `UNAUTHENTICATED` — confirmado com um cliente MCP real do host contra `http://127.0.0.1:8000/mcp`.
|
||||
3. **Sync real de entitlement**: `curl http://127.0.0.1:8181/v1/data/emcp/client_profiles` no OPA do compose mostra os profiles reais (`Sales.Read`, `Finance.Payments`, ...) que o Gateway empurrou no startup — não é um mock.
|
||||
4. **Segmentação de rede real (EP-05-T07)**: `opa`, `sales-domain` e `finance-domain` não publicam porta de host no compose — confirmado com `curl http://127.0.0.1:8100/mcp` do host retornando `Connection refused`, algo que só um daemon Docker real prova (Docker só expõe uma porta de container para o host via `ports:`, então a ausência dela já é a segmentação, sem precisar de uma rede Docker customizada adicional).
|
||||
5. **Caminho autenticado completo, cross-container, incluindo Finance**: com `--profile identity` (Keycloak real) e um token `client_credentials` real para `finance-payments-agent`, `tools/list` retorna exatamente os 3 tools de `Finance.Payments` (roteados pelo Fabric router para o container `finance-domain`, não `sales-domain`) e `tools/call finance.payment.create` executa de verdade contra o backend Finance real, retornando um `payment_id` genuíno.
|
||||
6. **Correlação de trace entre containers de verdade** (não em-processo, como nos testes e2e): o mesmo `trace_id` aparece nos logs de `gateway` e do domain server correspondente para uma chamada bem-sucedida — a forma mais forte de validar o critério de aceite do Marco M0.
|
||||
7. **Fail-closed real**: com o container `opa` parado, um novo container `gateway` falha ao subir (`Application startup failed. Exiting.`, `PDPUnavailableError` no traceback) — confirma README.md §38 sob Docker de verdade, não só em teste unitário.
|
||||
8. **Keycloak real, via `docker compose --profile core --profile identity up`** (EP-01-T01): realm `emcp` importado de `deploy/keycloak/realm-export.json` (3 clients — `sales-read-agent`, `sales-write-agent`, `finance-payments-agent` — cada um com `client_credentials` grant habilitado e um mapper de audience para `emcp-gateway`); token obtido via `client_credentials` real, validado pelo Gateway contra o JWKS real do Keycloak, `tools/list`/`tools/call` corretos para `Sales.Read`, negação correta cross-domain (`Sales.Read` → `finance.payment.execute`) e `REQUIRE_APPROVAL` correto para `Finance.Payments` acima do threshold — tudo com identidades **realmente distintas** emitidas pelo mesmo IdP.
|
||||
9. **`tests/e2e/test_real_keycloak.py`** automatiza o item 8 (sobe seu próprio container Keycloak via `docker run`, pula se `docker` não estiver no PATH) — 4 testes, agora estáveis com `--forked` (ver nota de flakiness acima).
|
||||
|
||||
O perfil `observability` (`otel-collector` + `jaeger`, EP-08-T04) não foi exercitado com tráfego real nesta rodada (apenas `docker compose config` validado) — os dois serviços usam imagens oficiais padrão (`otel/opentelemetry-collector-contrib`, `jaegertracing/all-in-one`) com uma config mínima (`deploy/otel/collector-config.yaml`), sem lógica própria da RI além da configuração em si.
|
||||
|
||||
**Três bugs reais encontrados e corrigidos ao longo da validação original** (nenhum seria pego sem um daemon Docker/Keycloak real):
|
||||
|
||||
- `deploy/docker/Dockerfile` não copiava `config/` para a imagem — o Gateway falhava ao subir (`ConfigError`: diretório `config/clients` não encontrado) porque `/app/config` simplesmente não existia no container. Corrigido com `COPY config ./config`.
|
||||
- O healthcheck de `opa` no `docker-compose.yml` usava `wget`, que **não existe** na imagem oficial `openpolicyagent/opa` (é uma imagem estilo distroless, só o binário `opa`, sem shell). Isso deixava `gateway` (que dependia de `opa: condition: service_healthy`) preso para sempre em `Created`. Corrigido: sem healthcheck em `opa` (não há ferramenta na imagem para escrevê-lo), `gateway` depende de `opa: condition: service_started` e ganhou `restart: on-failure:5` — o próprio fail-closed do Gateway (item 6 acima) faz o papel de "esperar o OPA ficar pronto de verdade", com um pequeno número de retries do Compose cobrindo a janela entre o processo do OPA iniciar e aceitar conexões.
|
||||
- Keycloak deriva o claim `iss` do `Host` header da requisição usada para *obter* o token, a menos que `KC_HOSTNAME` seja fixado — um token obtido via `127.0.0.1:8080` (do host, para teste manual) tinha `iss` diferente do que o Gateway (que valida via `keycloak:8080`, nome interno do compose) esperava, e era rejeitado por "wrong issuer" mesmo sendo um token 100% válido. Corrigido fixando `KC_HOSTNAME` (`keycloak` no compose; a URL completa `http://127.0.0.1:<porta>` no teste automatizado, já que `KC_HOSTNAME` não aceita `host:porta` sem esquema).
|
||||
|
||||
## Dois modos de deployment (importante)
|
||||
|
||||
O Gateway (`src/emcp_bus/gateway/server.py`) suporta dois modos, sempre *deny by default*:
|
||||
|
||||
1. **Com OIDC configurado** (`OIDC_JWKS_URL` + `OIDC_ISSUER` definidos): o próprio middleware de auth do SDK `mcp` (`RequireAuthMiddleware`) rejeita qualquer conexão sem bearer token válido **antes mesmo do handshake `initialize`** — mais estrito que qualquer checagem no nível do handler.
|
||||
2. **Sem OIDC configurado** (padrão se as env vars não estiverem definidas): nenhum middleware de auth é instalado; toda requisição chega aos handlers com `get_access_token() is None`, e é a própria lógica do handler (`on_list_tools`/`on_call_tool`) que nega por padrão — `tools/list` vazio, `tools/call` sempre negado com `Access denied: UNAUTHENTICATED`.
|
||||
|
||||
Os dois modos são testados explicitamente em `tests/e2e/test_governed_gateway.py`.
|
||||
|
||||
## M3 — Personalização e orquestração
|
||||
|
||||
Implementado e validado em 2026-09-14 (mesma sessão). Perfil → entitlement → ofertas filtradas → NBA → dispatch, ponta a ponta:
|
||||
|
||||
1. **Profile Intelligence** (`src/emcp_bus/profile_intelligence/`): `ProfileView` versionado, provider rule-based determinístico (mesmo `subject_ref`+janela ISO → mesmo `profileVersion`), guardrails de governança (pseudonimização, lineage log, stub de drift).
|
||||
2. **Offering Filter** (`src/emcp_bus/offering_filter/`): `FilteredOfferings = ActiveCatalog ∩ MaximumEntitlement ∩ ConsentContext` — invariante de subconjunto provada com Hypothesis (2000 casos gerados, `tests/unit/test_offering_filter_invariants.py`).
|
||||
3. **Service Orchestrator** (`src/emcp_bus/orchestrator/`): `StateGraph` real do LangGraph 0.6 com checkpointer SQLite real — nós `resolve_profile → resolve_entitlement → filter_offerings → decide_nba → approval_gate`, mais um caminho de reentrada para recálculo após DENY (`handle_denial`, nunca chama o Fabric diretamente). O nó `approval_gate` usa `langgraph.types.interrupt`/`Command(resume=...)` — pausa de verdade, sem timeout que vire ALLOW implícito, integrado ao `ApprovalService` do EP-14.
|
||||
4. **Agent Runtime** (`src/emcp_bus/agent_runtime/`): `AgentDispatcher` nunca gera/armazena credencial própria — usa exclusivamente o MCP Client já registrado, via `client_credentials` real contra o mesmo IdP de qualquer outro client. Channel adapter conversacional + Identity Resolver (`config/orchestrator/identity.yaml`, nunca consultado pelo PDP — checado via AST em teste).
|
||||
5. **Agente de exemplo** (`agents/example_agent/`): tool-calling real contra um endpoint OpenAI-compatible (verificado com `meta.llama-3.3-70b-instruct` via OCI Generative AI), catálogo restrito ao `tools/list` do próprio client. O cenário de prompt injection do §29 (pedir `finance.payment.execute` fora do entitlement) é reproduzido de verdade em `tests/e2e/test_example_agent.py` — a chamada nunca é executada, seja porque o modelo nunca viu a tool no seu próprio catálogo, seja porque o Gateway/PDP nega.
|
||||
6. **Langfuse self-hosted** (`deploy/langfuse/docker-compose.override.yml`, perfil `llm-observability`): adaptado do `docker-compose.yml` oficial do Langfuse (upstream, 2026-09-14) — 6 containers (web/worker/postgres/clickhouse/redis/minio). **Validado de verdade nesta sessão**: stack subida via `docker compose ... --profile llm-observability up`, um turno real do agente de exemplo (LLM real + Gateway real) com `OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` apontando para o endpoint OTLP nativo do Langfuse (`/api/public/otel/v1/traces`, sem nenhuma mudança de código — só variáveis de ambiente padrão do OTel SDK), e os spans (incluindo um span de "generation" com `gen_ai.request.model`/`gen_ai.usage.{input,output}_token_count` reais, via `agents/example_agent/agent.py`) confirmados fisicamente na tabela `events_core` do ClickHouse. Essa validação foi manual (não virou teste e2e automatizado, dado o custo de subir 6 containers a cada execução da suíte) — os comandos exatos estão documentados no histórico desta sessão; reproduzir localmente: `docker compose -f docker-compose.yml -f deploy/langfuse/docker-compose.override.yml --profile llm-observability up -d`, aguardar `curl http://localhost:3000/api/public/health`, criar um projeto/API key (UI ou `LANGFUSE_INIT_*`), apontar as duas env vars OTel acima.
|
||||
7. **Prompt versioning** (`agents/example_agent/prompts/store.py`, `could`): versionamento local (nunca sobrescreve, sempre cria versão nova) como núcleo testável — sync real para a API de Prompt Management do Langfuse é um adapter fino documentado, não implementado nesta sessão.
|
||||
|
||||
Dependências novas: `langgraph`, `langgraph-checkpoint-sqlite`, `hypothesis` (dev), `openai`, `python-dotenv` (dev). Nenhuma delas é necessária para M0-M2 (Gateway/PDP/Registry continuam funcionando sem elas).
|
||||
|
||||
## M4 — Hardening e entrega
|
||||
|
||||
Implementado e concluído em 2026-09-14 (mesma sessão do M3). Cobre EP-15 (testes de segurança/adversariais) completo, EP-16 (deploy final/documentação) completo, e os dois modos de integração multiagente do ADR-024 (EP-13-T05/T06, ambos `should`/`could` mas implementados):
|
||||
|
||||
1. **EP-15-T01 (testes de contrato)** (`tests/contract/`): conectam direto a cada domain server, nunca pelo Gateway — o contrato input/output de uma tool é propriedade do domain server (ADR-023), independente de quem está ou não entitled a chamá-la. **Achado durante a implementação**: `CapabilityManifest` (EP-02-T01) não carrega `input_schema`/`output_schema` — nunca fez parte do desenho do EP-02 (o manifesto é metadado de entitlement/risco, não formato). Resolvido sem reinterpretar silenciosamente o critério de aceite: (a) o `input_schema` **real**, auto-gerado pelo MCP SDK a partir da assinatura Python de cada tool, já é validado pelo próprio servidor (campo obrigatório ausente/tipo errado → `is_error: True` via Pydantic, antes de qualquer código da RI rodar) — exercitado diretamente, não reimplementado; (b) o `output_schema` auto-gerado é genérico demais (`additionalProperties: true`, já que toda tool retorna `dict[str, object]`) — resolvido com JSON Schema escrito à mão por tool (`jsonschema`, já dependência transitiva), validado contra a resposta real das 9 tools (6 Sales + 3 Finance). 20 testes.
|
||||
2. **EP-15-T02 (suíte de aceite de segurança, §45)** (`tests/e2e/test_security_acceptance.py`): roda contra o `docker-compose.yml` real (`--profile core --profile identity`, Keycloak incluído), não os fixtures in-process do resto da suíte e2e. 7 dos 10 testes formais automatizados diretamente: Teste 1 (unauthorized discovery), Teste 2 (direct unauthorized call), Teste 4 (role spoofing via payload forjado), Teste 5 (cache isolation, incluindo `cache_scope="private"`), Teste 7 (gateway bypass — `sales-domain`/`finance-domain`/`opa` não publicam porta de host), Teste 9 (backend/transaction-policy isolation, `TRANSACTION_POLICY_DENIED` real), Teste 10 (client compromise containment). Os outros 3 já têm cobertura real documentada no docstring do arquivo em vez de duplicada: Teste 3 (prompt injection, LLM real) em `test_example_agent.py`; Teste 6 (policy revocation, sem endpoint HTTP de revogação nesta RI) em `test_entitlement_manager.py`; Teste 8 (token audience) em `test_real_keycloak.py`, mesmo realm Keycloak real.
|
||||
3. **EP-15-T03 (testes adversariais de prompt/tool injection)** (`tests/adversarial/{test_prompt_injection,test_tool_poisoning,test_payload_identity_spoofing}.py`, 12 testes): contra o Gateway real. Injeção direta/indireta de conteúdo em argumentos nunca altera autorização nem o catálogo; hidden tool invocation e spoofing de nome (case/whitespace/typo) sempre negados por comparação exata; **tool description poisoning** provado com um `MCPServer` malicioso real montado em runtime (descrição hostil pedindo "acesso admin") — a Gateway repassa a descrição verbatim (nunca sanitiza) mas ela não tem efeito algum na decisão PDP; variação explícita do achado ADR-023 (`agent_id`/`tenant_id`/`business_context` forjados nos argumentos) provada por negação de entitlement e por auditoria (`tool_call_allowed` sempre registra o `client_id` real, nunca o forjado).
|
||||
4. **EP-15-T04 (testes adversariais de protocolo/identidade)** (`tests/adversarial/test_protocol_identity.py`, 6 testes + referências cruzadas para o que já era coberto alhures): header/body mismatch rejeitado pré-autenticação; replay de requisição idêntica (gap real e documentado — sem nonce nesta RI, mitigado por token de vida curta) mas sempre auditado com `decision_id` distinto; **revogação de política ao vivo** contra um Gateway rodando (fecha a lacuna do Teste 6 que EP-15-T02 havia documentado); condição de corrida de entitlement sob concorrência real sem crash; **approval replay sob concorrência real** (`ThreadPoolExecutor`) — achado real: `ApprovalService.check_and_consume` não era thread-safe, corrigido com `threading.Lock`; bulk exfiltration (gap real e documentado — sem rate limiting) sempre com trilha de auditoria completa.
|
||||
5. **EP-15-T05 (testes de política de falha, §38)** (`tests/e2e/test_fail_closed.py`, 7 testes, um por linha da tabela): IdP inalcançável nega novas autenticações; PDP morrendo em runtime (não só no boot) falha fechado na próxima chamada; Registry sem `capabilities/` falha o boot do Gateway; **achado real corrigido**: nenhuma das 8 chamadas `audit_sink.emit` do Gateway estava protegida — uma falha no sink teria virado erro 500 numa operação já ALLOWed, corrigido com o helper `_emit_audit`; approval service falho nega (nunca crasha) uma operação de alto risco; backend morto falha só aquela requisição, sem derrubar o Gateway.
|
||||
6. **EP-13-T05 (Modo A — multiagente governado, ADR-024)** (`src/emcp_bus/agent_runtime/global_supervisor.py` + `agents/example_agent/backends/{sales_agent,finance_agent}/`): `GlobalSupervisor` com roteamento por palavra-chave simples (deliberadamente não é a fronteira de segurança — documentado), nunca importa `emcp_bus.orchestrator` (checado via AST). Validado com **LLM real + Gateway real** (`tests/e2e/test_multi_agent_mode_a.py`, 2 testes): handoff de `sales_agent` para `finance_agent` resolve entitlement do zero com token completamente distinto; injeção de prompt via handoff tentando `finance.payment.execute` continua negada pelo Gateway/PDP reais.
|
||||
7. **EP-13-T06 (Modo B — Orchestrator como único cérebro de NBA/NBO, `could`)** (`agents/example_agent/mode_b_orchestrator_driven/`): `ModeBOrchestrator` invoca o journey `StateGraph` real do EP-11-T02 (não modificado) uma vez por client relevante para o mesmo subject, combina os `NBADecision`s (Finance sempre vence Sales na regra de referência) e despacha via `AgentDispatcher` real — nunca cria autorização própria. Validado **contra Keycloak real, OPA real, Sales+Finance reais** (`tests/e2e/test_real_keycloak.py::test_mode_b_orchestrator_combines_offerings_and_dispatches_through_the_real_chain`): candidatos de ambos os domínios realmente combinados, decisão final do nosso Orchestrator, dispatch `ALLOW` de ponta a ponta.
|
||||
8. **EP-16 (deploy final e documentação)**: `docker-compose.yml` validado com `--profile core --profile identity --profile observability up -d --build` real (7 serviços saudáveis); 25 ADRs formalizados em `docs/adr/` (ADR-001 a ADR-025, gerados a partir da tabela §8.4 do planning); versões confirmadas consolidadas em `docs/adr/ADR-018-stack-de-implementacao.md`.
|
||||
|
||||
**Duas correções de código reais** (não apenas testes) surgiram escrevendo os testes adversariais/fail-closed: `ApprovalService` ganhou um `threading.Lock` (race condition real de double-spend sob concorrência), e o Gateway ganhou `_emit_audit` (falha de audit sink podia virar erro 500 numa operação já decidida, violando §38).
|
||||
|
||||
## O que existe hoje
|
||||
|
||||
| Tarefa | Componente | Status |
|
||||
|---|---|---|
|
||||
| EP-00 (completo) | Scaffold, config loader, docker-compose base, **CI** | ✅ `.github/workflows/ci.yml` (EP-00-T03) |
|
||||
| EP-01 (completo) | Registro de MCP Client, middleware OIDC/JWT, Keycloak, lint de shared client, spike mTLS | ✅ validado com JWKS real + tokens RS256 reais + Keycloak real (26.7.3, `tests/e2e/test_real_keycloak.py`); lint em `src/emcp_bus/identity/shared_client_lint.py` (ligado a `make validate-schemas`); spike em `docs/adr/ADR-019-identity-extension.md` |
|
||||
| EP-02-T01..T05 (completo) | Registry: schema, pipeline, storage, lifecycle, manifests de exemplo | ✅ `src/emcp_bus/registry/*`, testado (inclui `seed.py`, usado pelo Gateway em runtime desde EP-06-T01) |
|
||||
| EP-03 (completo) | PDP/OPA: entitlement.rego, transaction.rego, cliente, contrato de decisão | ✅ validado com **OPA real** — 9 testes Rego + 6 Python |
|
||||
| EP-04 (completo) | Entitlement Manager: resolve, revoke, versionamento | ✅ validado com OPA real + YAML reais |
|
||||
| EP-05 (completo) | Gateway: discovery filtrado, execution enforcement, fail-closed, **cache hint**, **anti-bypass**, **consistência header/body** | ✅ `src/emcp_bus/gateway/{server,cache,canonical_request}.py` — ver limitação de wire-mode do cache hint abaixo |
|
||||
| EP-06-T01 | Fabric core (roteador por capability via Registry) | ✅ `src/emcp_bus/fabric/router.py` — multi-backend real, testado (Sales→sales-domain, Finance→finance-domain, nunca cruzado) |
|
||||
| EP-06-T02 | MCP Server de exemplo — Sales | ✅ 6 tools (R1 + R2), agora com `BackendCredentialGate` (EP-05-T07) |
|
||||
| EP-06-T03 | MCP Server de exemplo — Finance | ✅ `services/example_mcp_servers/finance_domain/server.py` — invoice.get (R1), payment.create (R2), payment.execute (R3, ciclo de aprovação completo testado e2e) |
|
||||
| EP-06-T04 | Adapter REST genérico | ✅ `src/emcp_bus/fabric/adapters/rest_adapter.py` — implementado e testado standalone (`httpx.MockTransport`); nenhum manifest seed usa `backend.type: "rest"` ainda (os dois domínios de exemplo são MCP-nativos), então não há caminho ao vivo através do Gateway exercitando-o — ver módulo docstring |
|
||||
| EP-07 (completo) | Outbound/downstream identity: credencial de backend distinta da do client, **token exchange RFC 8693** | ✅ `src/emcp_bus/downstream/{models,identity}.py` — `service_account` (padrão dos backends seed) **e** `token_exchange` (Keycloak Standard Token Exchange, validado contra Keycloak real 2026-09-14) |
|
||||
| EP-08-T01 | Tracing OTel | ✅ (M0) |
|
||||
| EP-08-T02 | Eventos de auditoria (taxonomia IC/NOC/GRL) | ✅ `src/emcp_bus/audit/{models,events,sink}.py` — nunca registra valor de token/secret (hash apenas), sink JSONL append-only |
|
||||
| EP-08-T03 | Correlação ponta a ponta (decisionId/policyDecisionId/entitlementVersion/mcpRequestId) | ✅ `src/emcp_bus/audit/correlation.py` — exposto no trace (span attributes) e em todo evento de auditoria |
|
||||
| EP-08-T04 | Stack local de observabilidade (OTel Collector + Jaeger) | ✅ `docker-compose.yml` perfil `observability` + `deploy/otel/collector-config.yaml` — config validada (`docker compose config`), não exercitada com tráfego real nesta rodada |
|
||||
| EP-14 (completo) | Approval workflow: contrato, serviço, fail-closed, ligado ao Gateway | ✅ `src/emcp_bus/approval/{models,service}.py`, testado (ciclo completo e2e para Sales **e** Finance: nega → aprova externamente → executa → replay nega) |
|
||||
| EP-09 (completo) | Profile Intelligence: contrato, provider rule-based, governança | ✅ `src/emcp_bus/profile_intelligence/*.py` |
|
||||
| EP-10 (completo) | Offering Filter: serviço + invariante de subconjunto | ✅ `src/emcp_bus/offering_filter/service.py` — invariante provada com Hypothesis (2000 casos) |
|
||||
| EP-11 (completo) | Service Orchestrator: DecisionContext, StateGraph LangGraph, decisioning pluggable, recálculo, HITL | ✅ `src/emcp_bus/orchestrator/*.py` — LangGraph + checkpointer SQLite reais, checkpoint-resume e pausa/retomada de aprovação validados |
|
||||
| EP-12 (completo) | Agent Runtime: dispatch, channel adapter, handoff, identity resolver | ✅ `src/emcp_bus/agent_runtime/*.py` — validado contra Gateway + Keycloak reais |
|
||||
| EP-13-T01/T03/T04 | Agente de exemplo (LLM real), redação de PII, versionamento de prompt | ✅ `agents/example_agent/*.py` — tool-calling real, cenário de prompt injection do §29 reproduzido |
|
||||
| EP-13-T02 | Langfuse self-hosted | ✅ `deploy/langfuse/docker-compose.override.yml` — subido e validado de verdade (ver seção M3 acima); validação manual, não automatizada em CI |
|
||||
| EP-15-T01 | Testes de contrato dos MCP Servers | ✅ `tests/contract/*.py` — input schema real do SDK exercitado + output schema escrito à mão, validado contra os 2 domain servers reais (ver seção M4 acima) |
|
||||
| EP-15-T02 | Suíte de aceite de segurança (§45) | ✅ `tests/e2e/test_security_acceptance.py` — 7/10 testes formais automatizados contra o docker-compose real; os outros 3 já cobertos em outros arquivos (ver seção M4 acima) |
|
||||
| EP-15-T03 | Testes adversariais de prompt/tool injection | ✅ `tests/adversarial/{test_prompt_injection,test_tool_poisoning,test_payload_identity_spoofing}.py` — 12 testes, incluindo um MCP server malicioso real (ver seção M4 acima) |
|
||||
| EP-15-T04 | Testes adversariais de protocolo/identidade | ✅ `tests/adversarial/test_protocol_identity.py` — 6 testes novos + `threading.Lock` real corrigido em `ApprovalService` (ver seção M4 acima) |
|
||||
| EP-15-T05 | Testes de política de falha (§38) | ✅ `tests/e2e/test_fail_closed.py` — 7 testes, um por linha da tabela + `_emit_audit` real corrigido no Gateway (ver seção M4 acima) |
|
||||
| EP-13-T05 (`should`) | Modo A — multiagente governado (ADR-024) | ✅ `src/emcp_bus/agent_runtime/global_supervisor.py` + `agents/example_agent/backends/*` — validado com LLM real + Gateway real (ver seção M4 acima) |
|
||||
| EP-13-T06 (`could`) | Modo B — Orchestrator como único cérebro de NBA/NBO | ✅ `agents/example_agent/mode_b_orchestrator_driven/*` — validado com Keycloak real + OPA real + Sales/Finance reais (ver seção M4 acima) |
|
||||
| EP-16 (completo) | Deploy final, ADRs formalizados, README | ✅ `docker-compose.yml` validado com todos os perfis; 25 ADRs em `docs/adr/`; versões confirmadas em `docs/adr/ADR-018-stack-de-implementacao.md` |
|
||||
|
||||
**260 testes automatizados no total**: 253 passam com `--forked` (unit + e2e + contract + adversarial — todo `tests/` exceto `test_security_acceptance.py`, confirmado em execução completa 2026-09-14), mais 7 testes de aceite de segurança (EP-15-T02) contra o `docker-compose.yml` real (build + Keycloak, ~72s, rodados separadamente do `--forked` padrão pelo custo por execução). Lógica de produção sem mocks no núcleo de segurança e orquestração: OPA real (subprocess), Keycloak real (Docker), LangGraph real com checkpointer SQLite real, LLM real (endpoint OpenAI-compatible), JWKS HTTP real (stand-in leve para o resto da suíte) + JWT RS256 reais, YAML reais de `config/`, SQLite real para o Registry. **100% determinísticos com `--forked`** (verificado em múltiplas execuções seguidas) — a flakiness antes documentada para a suíte e2e está resolvida, ver seção dedicada acima.
|
||||
|
||||
## Limitações conhecidas
|
||||
|
||||
- **SEP-2549 (`ttlMs` do cache hint de `tools/list`, EP-05-T03)**: implementado corretamente (`src/emcp_bus/gateway/cache.py`), mas o campo é vocabulário exclusivo do protocolo 2026-07-28, que só existe no modo *stateless per-request* do SDK (sem handshake `initialize`) — esta RI usa o handshake de sessão clássico em toda parte (Gateway↔Client e Gateway↔backends), então `ttl_ms` nunca chega ao wire nesta arquitetura, não importa o que o Gateway compute (o SDK descarta o campo na serialização para versões de protocolo anteriores a 2026-07-28 — verificado empiricamente). `cache_scope` não sofre essa limitação de wire, mas por não ter dependência de wire-mode também não é algo que um teste e2e consiga verificar de forma discriminante (o cliente sempre usa o default do próprio campo). A propriedade de segurança real (Teste 5, §45 — nunca vazar catálogo entre clientes) é garantida de forma independente pelo Gateway nunca cachear o catálogo filtrado no servidor.
|
||||
- **EP-05-T08 (consistência header/body)**: implementado como um gate próprio (`canonical_request.py`) reaproveitando as constantes/nomes de campo do SDK, e não como o ladder de validação nativo do SDK (`mcp.shared.inbound.classify_inbound_request`), que só se aplica ao mesmo modo *stateless per-request* de 2026-07-28 mencionado acima — pela mesma razão, este RI não o alcança pelo caminho nativo.
|
||||
- **EP-06-T04 (adapter REST)**: implementado e testado de forma standalone; nenhum backend de exemplo desta RI é REST (ambos são MCP-nativos), então o roteamento ao vivo através do Gateway para um backend `type: "rest"` não foi exercitado end-to-end.
|
||||
- **Stack de observabilidade** (EP-08-T04): config validada, não exercitada com tráfego real (ver "O que foi validado com Docker").
|
||||
|
||||
~~Token exchange RFC 8693 (`NotImplementedError`)~~ — **resolvido em 2026-09-14** (início do M3): `TokenExchangeClient` implementa o Standard Token Exchange do Keycloak (GA desde 26.2) de ponta a ponta, validado contra Keycloak real, incluindo um `tools/call` completo pelo Gateway usando uma credencial obtida por exchange. Ver `docs/RI-PLANNING.md` §8.7 para os detalhes verificados (exigências reais do IdP: `audience` precisa ser um client id registrado; `scope` precisa acompanhar `audience` ou o exchange falha).
|
||||
|
||||
**M3, ressalvas (2026-09-14):**
|
||||
|
||||
- **EP-13-T02 (Langfuse)**: validado manualmente de verdade (ver seção M3 acima — spans reais confirmados no ClickHouse), mas não é um teste e2e automatizado por causa do custo de subir 6 containers a cada execução da suíte.
|
||||
- **EP-13-T04 (prompt versioning, `could`)**: o núcleo de versionamento é real e testado; o sync com a API de Prompt Management do Langfuse (`langfuse.api.prompts.create`) é um adapter fino documentado, não implementado.
|
||||
- **Credencial de LLM**: `LLM_API_KEY`/`LLM_BASE_URL`/`LLM_MODEL` (ver `.env.example`) são necessárias para `tests/e2e/test_example_agent.py` e `tests/e2e/test_multi_agent_mode_a.py` — sem elas, esses testes são pulados (`pytest.skip`), não falham, igual ao padrão já usado para `opa`/`docker`.
|
||||
|
||||
**M4, ressalvas (2026-09-14):**
|
||||
|
||||
- **EP-12-T03 (`execute_handoff`/`build_handoff_payload`, `could`)**: continua implementado e testado (`tests/unit/test_agent_runtime_handoff.py`), mas **achado real durante o M4**: nem o Modo A (EP-13-T05) nem o Modo B (EP-13-T06) acabaram usando esse primitivo. Modo A precisa de roteamento conversacional (qual backend responde à próxima mensagem), não de despacho de uma `NBADecision` específica — `GlobalSupervisor` chama `run_turn()` diretamente nos backends. Modo B já combina `NBADecision`s de múltiplos clients num único passo (`ModeBOrchestrator._combine`) e despacha o vencedor via `AgentDispatcher.dispatch` puro — não há um agente "de origem" cedendo controle a outro para `execute_handoff` interceptar. `execute_handoff` continua correto e reutilizável para um cenário futuro que precise especificamente de handoff *dentro* de uma jornada Orchestrator-driven (ex.: EP-11 decidindo, no meio de uma jornada, redirecionar para outro agente mantendo o mesmo `NBADecision`), mas essa forma específica de uso não existe nesta RI.
|
||||
- **Replay de requisição** (`tests/adversarial/test_protocol_identity.py`): sem nonce/anti-replay nesta RI — uma requisição capturada com um token ainda válido pode ser reenviada com sucesso. Mitigado por tokens de vida curta (`credential_policy.short_lived` em todo `ClientProfile` seed) e por auditoria completa (cada tentativa, mesmo idêntica, gera `decision_id` próprio).
|
||||
- **Rate limiting / bulk exfiltration** (`tests/adversarial/test_protocol_identity.py`): sem limitação de taxa de requisições no Gateway — N leituras legítimas consecutivas são todas permitidas. Mitigado apenas por detectabilidade a posteriori via auditoria (cada chamada gera evento correlacionado), nunca por prevenção em tempo real.
|
||||
- **`GlobalSupervisor` (Modo A, EP-13-T05)**: roteamento por palavra-chave simples, deliberadamente não é a fronteira de segurança (documentado no módulo) — não é uma reivindicação de qualidade de roteamento, apenas um stand-in honesto para o que uma plataforma externa real faria.
|
||||
- **`ModeBOrchestrator` (Modo B, EP-13-T06)**: a regra de combinação entre domínios (Finance sempre vence Sales) é uma regra de referência simples, não uma reivindicação de otimalidade — mesma postura já adotada por `RuleBasedNBADecisionModel` (EP-11-T03). Não estende `JourneyState`/`build_journey_graph` para múltiplos `client_id`s nativamente; a combinação vive numa camada acima do grafo (decisão de design documentada no módulo).
|
||||
- **EP-06-T04 (adapter REST)**, **stack de observabilidade** (EP-08-T04, tráfego real ainda não exercitado por um teste automatizado — mas a subida completa com o perfil `observability` foi validada manualmente no M4) e o restante das ressalvas de M0-M3 acima continuam válidas sem mudança.
|
||||
|
||||
## Versões fixadas
|
||||
|
||||
Ver `pyproject.toml`. Confirmadas via `pip index versions`/download direto em 2026-09-11 (não citadas de memória — ver `docs/RI-PLANNING.md`, EP-16-T04):
|
||||
|
||||
- `mcp` 2.2.0 (SDK v2 — API mudou significativamente da v1; ver comentários em `src/emcp_bus/gateway/server.py`)
|
||||
- `pydantic` 2.13.x, `opentelemetry-sdk` 1.44.x, `uvicorn` 0.52.x, `pyjwt[crypto]` 2.14.x, `ruff` 0.16.x, `mypy` 2.3.x, `pytest` 9.1.x
|
||||
- `OPA` 1.20.2 (binário estático, Rego v1 padrão)
|
||||
- `pytest-forked` 1.7.x (resolve a flakiness da suíte e2e, ver acima)
|
||||
- `langgraph` 0.6.x + `langgraph-checkpoint-sqlite` 2.x (M3, EP-11), `hypothesis` 6.1xx.x (dev, EP-10-T02), `openai` 1.109.x (M3, EP-13-T01), `python-dotenv` 1.2.x (dev) — confirmadas via instalação direta em 2026-09-14
|
||||
- Langfuse self-hosted 4.35.0 (`docker.langfuse.com/langfuse/langfuse:4`, imagem oficial, EP-13-T02)
|
||||
- `types-jsonschema` 4.26.x (dev, stubs para `mypy --strict`; `jsonschema` em si já era dependência transitiva do `mcp[cli]`, EP-15-T01) — confirmada via instalação direta em 2026-09-14
|
||||
- Keycloak 26.7.3 (`quay.io/keycloak/keycloak:26.7.3`, Quarkus), `opentelemetry-collector-contrib` 0.114.0, `jaeger` (all-in-one) 1.62.0 — imagens fixadas em `docker-compose.yml`; registro completo em `docs/adr/ADR-018-stack-de-implementacao.md` (EP-16-T04)
|
||||
Ver [`docs/How-To.md`](docs/How-To.md#subindo-a-stack-local) para os perfis disponíveis
|
||||
(`core`, `identity`, `observability`) e o que cada um adiciona.
|
||||
|
||||
## Estrutura
|
||||
|
||||
Ver `../docs/RI-PLANNING.md`, seção 8.5, para a árvore completa planejada. O que existe até agora:
|
||||
|
||||
```text
|
||||
RI/
|
||||
├── pyproject.toml
|
||||
├── Makefile
|
||||
├── docker-compose.yml # perfil "core": gateway+sales-domain+finance-domain+opa;
|
||||
│ # "identity": +keycloak; "observability": +otel-collector+jaeger
|
||||
├── docker-compose.yml # perfis: core (gateway+sales-domain+finance-domain+opa),
|
||||
│ # identity (+keycloak), observability (+otel-collector+jaeger)
|
||||
├── .env.example
|
||||
├── deploy/docker/Dockerfile
|
||||
├── deploy/keycloak/realm-export.json # EP-01-T01: realm "emcp", 3 clients
|
||||
├── deploy/otel/collector-config.yaml # EP-08-T04
|
||||
├── deploy/
|
||||
│ ├── docker/Dockerfile
|
||||
│ ├── keycloak/realm-export.json # realm "emcp", 3 clients
|
||||
│ ├── opa/
|
||||
│ ├── otel/collector-config.yaml
|
||||
│ └── langfuse/docker-compose.override.yml # perfil llm-observability, opt-in
|
||||
├── scripts/{export_schemas,validate_config}.py
|
||||
├── schemas/*.schema.json # gerados por export_schemas.py
|
||||
├── config/
|
||||
│ ├── policies/ # EP-03: entitlement.rego, transaction.rego (+ opa test)
|
||||
│ ├── clients/{*.yaml,profiles/*.yaml} # EP-01/EP-04: registrations + profiles
|
||||
│ ├── backends/*.yaml # EP-06-T01/EP-07: endpoint + outbound identity por backend
|
||||
│ └── capabilities/{sales,finance}/*.yaml # EP-02-T05: manifests de exemplo
|
||||
│ ├── env/{dev,ci}.yaml
|
||||
│ ├── policies/ # entitlement.rego, transaction.rego
|
||||
│ ├── clients/{*.yaml,profiles/*.yaml} # registrations + client profiles
|
||||
│ ├── backends/*.yaml # endpoint + outbound identity por backend
|
||||
│ ├── capabilities/{sales,finance}/*.yaml
|
||||
│ └── orchestrator/identity.yaml
|
||||
├── src/emcp_bus/
|
||||
│ ├── common/{config,otel,models}.py # EP-00-T02, EP-08-T01, risk tier compartilhado
|
||||
│ ├── identity/{models,authn,token_verifier,shared_client_lint}.py # EP-01
|
||||
│ ├── entitlement/{models,manager}.py # EP-04
|
||||
│ ├── registry/{models,store,pipeline,lifecycle,seed}.py # EP-02
|
||||
│ ├── pdp/{client,models}.py # EP-03
|
||||
│ ├── fabric/router.py # EP-06-T01
|
||||
│ ├── fabric/adapters/rest_adapter.py # EP-06-T04 (standalone, ver limitações)
|
||||
│ ├── downstream/{models,identity}.py # EP-07
|
||||
│ ├── audit/{models,events,sink,correlation,bypass_detection}.py # EP-08-T02/T03, EP-05-T07
|
||||
│ ├── approval/{models,service}.py # EP-14
|
||||
│ ├── gateway/{server,cache,canonical_request}.py # EP-05, integra tudo acima
|
||||
│ ├── profile_intelligence/{models,rule_based_provider,governance}.py # EP-09
|
||||
│ ├── offering_filter/service.py # EP-10
|
||||
│ ├── orchestrator/{decision_context,state,graph,nba_model,decisioning,fallback,hitl_node}.py # EP-11
|
||||
│ └── agent_runtime/{dispatcher,identity_resolver,handoff_graph,global_supervisor,channels/*}.py # EP-12, EP-13-T05
|
||||
│ ├── common/{config,otel,models,pii_redaction}.py
|
||||
│ ├── identity/{models,authn,token_verifier,shared_client_lint}.py
|
||||
│ ├── entitlement/{models,manager}.py
|
||||
│ ├── registry/{models,store,pipeline,lifecycle,seed}.py
|
||||
│ ├── pdp/{client,models}.py
|
||||
│ ├── fabric/{router,adapters/rest_adapter}.py
|
||||
│ ├── downstream/{models,identity}.py
|
||||
│ ├── audit/{models,events,sink,correlation,bypass_detection}.py
|
||||
│ ├── approval/{models,service}.py
|
||||
│ ├── gateway/{server,cache,canonical_request}.py
|
||||
│ ├── profile_intelligence/{models,rule_based_provider,governance}.py
|
||||
│ ├── offering_filter/service.py
|
||||
│ ├── orchestrator/{decision_context,state,graph,nba_model,decisioning,fallback,hitl_node}.py
|
||||
│ └── agent_runtime/{dispatcher,identity_resolver,handoff_graph,global_supervisor,channels/*}.py
|
||||
├── services/example_mcp_servers/
|
||||
│ ├── sales_domain/server.py # EP-06-T02 (6 tools: R1 + R2)
|
||||
│ └── finance_domain/server.py # EP-06-T03 (invoice.get R1, payment.create R2, payment.execute R3)
|
||||
│ ├── sales_domain/server.py # 6 tools: R1 + R2
|
||||
│ └── finance_domain/server.py # invoice.get R1, payment.create R2, payment.execute R3
|
||||
├── agents/example_agent/
|
||||
│ ├── agent.py, llm_client.py # EP-13-T01
|
||||
│ ├── prompts/{store.py,v1.txt} # EP-13-T04
|
||||
│ ├── backends/{sales_agent,finance_agent}/ # EP-13-T05, Modo A (ADR-024)
|
||||
│ └── mode_b_orchestrator_driven/{decision_model,orchestrator}.py # EP-13-T06, Modo B (ADR-024)
|
||||
├── deploy/langfuse/docker-compose.override.yml # EP-13-T02
|
||||
├── docs/adr/ # ADR-001..025 formalizados (EP-16-T03), um arquivo por decisão
|
||||
│ ├── agent.py, llm_client.py
|
||||
│ ├── prompts/{store.py,v1.txt}
|
||||
│ ├── backends/{sales_agent,finance_agent}/ # Modo A (ADR-024)
|
||||
│ └── mode_b_orchestrator_driven/{decision_model,orchestrator}.py # Modo B (ADR-024)
|
||||
├── docs/
|
||||
│ ├── AS-BUILT.md
|
||||
│ ├── How-To.md
|
||||
│ ├── Assumptions.md
|
||||
│ ├── Production-Recommendations.md
|
||||
│ └── interfaces/*.md
|
||||
└── tests/
|
||||
├── unit/ # schemas, PDP, entitlement, authn, registry, downstream identity, approval,
|
||||
│ # fabric router, audit, canonical request, rest adapter, bypass detection,
|
||||
│ # gateway cache, shared client lint, profile intelligence, offering filter
|
||||
│ # (+ invariants), orchestrator (decision_context/decisioning/fallback/hitl/graph),
|
||||
│ # agent_runtime (channels/identity_resolver/handoff/global_supervisor),
|
||||
│ # pii_redaction, prompt store
|
||||
├── e2e/ # conftest.py (fixtures compartilhados), walking skeleton, governed gateway,
|
||||
│ # test_real_keycloak.py (EP-01-T01/T12, token exchange, Modo B, Docker real),
|
||||
│ # test_example_agent.py (EP-13-T01, LLM real),
|
||||
│ # test_security_acceptance.py (EP-15-T02, docker-compose real),
|
||||
│ # test_multi_agent_mode_a.py (EP-13-T05, LLM real),
|
||||
│ # test_fail_closed.py (EP-15-T05)
|
||||
├── contract/ # conftest.py + test_sales_domain.py + test_finance_domain.py (EP-15-T01)
|
||||
└── adversarial/ # test_prompt_injection.py, test_tool_poisoning.py,
|
||||
# test_payload_identity_spoofing.py (EP-15-T03),
|
||||
# test_protocol_identity.py (EP-15-T04)
|
||||
├── unit/
|
||||
├── e2e/ # walking skeleton, governed gateway, real Keycloak, example agent,
|
||||
│ # security acceptance, multi-agent Modo A, fail-closed
|
||||
├── contract/ # contrato input/output de cada domain server
|
||||
└── adversarial/ # prompt/tool injection, protocolo/identidade
|
||||
```
|
||||
|
||||
## Versões fixadas
|
||||
|
||||
Ver `pyproject.toml`. Principais: `mcp` 2.2.0, `pydantic` 2.13.x, `opentelemetry-sdk` 1.44.x,
|
||||
`uvicorn` 0.52.x, `pyjwt[crypto]` 2.14.x, `ruff` 0.16.x, `mypy` 2.3.x, `pytest` 9.1.x,
|
||||
`pytest-forked` 1.7.x, `langgraph` 0.6.x + `langgraph-checkpoint-sqlite` 2.x, `hypothesis`
|
||||
6.1xx.x (dev), `openai` 1.109.x, `python-dotenv` 1.2.x (dev), OPA v1.20.2 (binário estático, Rego
|
||||
v1 padrão), Keycloak 26.7.3, Langfuse self-hosted 4.35.0, `opentelemetry-collector-contrib`
|
||||
0.114.0, `jaeger` (all-in-one) 1.62.0. Registro completo com procedência de cada versão em
|
||||
`docs/adr/ADR-018-stack-de-implementacao.md`.
|
||||
|
||||
398
RI/docs/AS-BUILT.en-us.md
Normal file
398
RI/docs/AS-BUILT.en-us.md
Normal file
@@ -0,0 +1,398 @@
|
||||
# AS-BUILT — Enterprise MCP Service Bus (RI)
|
||||
|
||||
Technical reference for what exists today in the Reference Implementation (RI): the
|
||||
responsibility of each component, where the code lives, high-level input/output contracts (the
|
||||
fine-grained detail of each contract is in [`interfaces/`](interfaces/)), what was validated and
|
||||
against which real dependency, and the real architectural limitations found during
|
||||
implementation.
|
||||
|
||||
Target audience: those who will **integrate with or extend** the RI. For those who will **run**
|
||||
the RI, see [`How-To.md`](How-To.md). For those who will **evaluate/adapt** the RI for a real
|
||||
scenario, see [`Assumptions.md`](Assumptions.md) and
|
||||
[`Production-Recommendations.md`](Production-Recommendations.md).
|
||||
|
||||
This document succeeds the planning backlog `docs/RI-PLANNING.md` (removed from the repository
|
||||
after the completion of milestone M4, with history preserved in git) — the factual information
|
||||
from that backlog that is still relevant to whoever uses the finished product was migrated here,
|
||||
to `Assumptions.md`, and to `Production-Recommendations.md`.
|
||||
|
||||
## Status
|
||||
|
||||
The entire planned backlog (milestones M0 through M4) is implemented, tested, and validated.
|
||||
**261 automated tests** (count verified via `pytest --collect-only` in this revision): 254 pass
|
||||
with `pytest --forked` (unit + e2e + contract + adversarial), plus 7 security acceptance tests
|
||||
(`tests/e2e/test_security_acceptance.py`) that require real Docker and are run separately due to
|
||||
the build+Keycloak cost per run. Production logic with no mocks in the security and
|
||||
orchestration core: real OPA (subprocess), real Keycloak (Docker), real LangGraph with SQLite
|
||||
checkpointer, a real LLM (OpenAI-compatible endpoint), real HTTP JWKS (a lightweight stand-in for
|
||||
the rest of the e2e suite) with real RS256 JWTs, real YAML from `config/`, real SQLite for the
|
||||
Registry.
|
||||
|
||||
## Epic index
|
||||
|
||||
Every `EP-XX-TXX` that appears in comments/docstrings in the source code references one of these
|
||||
epics. This table is what replaces `docs/RI-PLANNING.md` §8.6 as the context source for those
|
||||
references.
|
||||
|
||||
| Epic | Title | Main module/test |
|
||||
|---|---|---|
|
||||
| EP-00 | RI bootstrap and tooling | `pyproject.toml`, `Makefile`, `.github/workflows/ci.yml`, `src/emcp_bus/common/config.py` |
|
||||
| EP-01 | MCP Client identity and authentication | `src/emcp_bus/identity/*.py`, `deploy/keycloak/realm-export.json` |
|
||||
| EP-02 | Global Capability Registry and publishing pipeline | `src/emcp_bus/registry/*.py` |
|
||||
| EP-03 | PDP / Policy Engine (OPA) | `src/emcp_bus/pdp/*.py`, `config/policies/*.rego` |
|
||||
| EP-04 | Entitlement Manager | `src/emcp_bus/entitlement/*.py` |
|
||||
| EP-05 | MCP Gateway / PEP | `src/emcp_bus/gateway/*.py` |
|
||||
| EP-06 | Enterprise MCP Fabric and example domain servers | `src/emcp_bus/fabric/*.py`, `services/example_mcp_servers/*` |
|
||||
| EP-07 | Downstream identity (inbound vs outbound) | `src/emcp_bus/downstream/*.py` |
|
||||
| EP-08 | Auditing, correlation and observability | `src/emcp_bus/audit/*.py`, `src/emcp_bus/common/otel.py` |
|
||||
| EP-09 | Profile Intelligence (generic contract) | `src/emcp_bus/profile_intelligence/*.py` |
|
||||
| EP-10 | Offering Filter | `src/emcp_bus/offering_filter/service.py` |
|
||||
| EP-11 | Service Orchestrator (NBA/NBO) | `src/emcp_bus/orchestrator/*.py` |
|
||||
| EP-12 | Agent Runtime and channel adapters | `src/emcp_bus/agent_runtime/*.py` |
|
||||
| EP-13 | Example agent and Langfuse instrumentation | `agents/example_agent/*` |
|
||||
| EP-14 | Human-in-the-loop / Approval workflow | `src/emcp_bus/approval/*.py` |
|
||||
| EP-15 | Security and adversarial tests | `tests/contract/`, `tests/e2e/test_security_acceptance.py`, `tests/adversarial/`, `tests/e2e/test_fail_closed.py` |
|
||||
| EP-16 | Final deployment and documentation | `docker-compose.yml`, `docs/adr/` |
|
||||
|
||||
Within each epic, `TXX` numbers the tasks (e.g., `EP-05-T03` = third task of epic EP-05/Gateway —
|
||||
the `tools/list` cache hint). The ADRs referenced by number (`ADR-XXX`) are individual files in
|
||||
[`../../docs/adr/`](../../docs/adr/).
|
||||
|
||||
## Components
|
||||
|
||||
### MCP Client identity (EP-01)
|
||||
|
||||
**Responsibility:** authenticate the workload identity making the call, never the
|
||||
self-declared identity in `clientInfo` or in any payload field. `src/emcp_bus/identity/authn.py`
|
||||
(`TokenValidator`) validates an OAuth2/OIDC bearer token against the IdP's JWKS (RS256
|
||||
signature, `exp`, `iss`, `aud`); the trusted `client_id` comes exclusively from the `azp`/
|
||||
`client_id` claim of the verified token. `src/emcp_bus/identity/models.py`
|
||||
(`ClientRegistration`) maps that `client_id` to exactly one `ClientProfile` (EP-04) via YAML in
|
||||
`config/clients/*.yaml`. `shared_client_lint.py` (EP-01-T05) validates in CI that two distinct
|
||||
agents never share the same `client_id` under different profiles.
|
||||
|
||||
**Validated against:** real Keycloak 26.7.3 via Docker (`tests/e2e/test_real_keycloak.py`) — the
|
||||
`emcp` realm imported from `deploy/keycloak/realm-export.json`, 3 clients (`sales-read-agent`,
|
||||
`sales-write-agent`, `finance-payments-agent`) with `client_credentials` grant; and a lighter
|
||||
local JWKS HTTP server as a stand-in for the rest of the e2e suite (valid for validation logic,
|
||||
not for real protocol integration with the IdP).
|
||||
|
||||
**Production extension documented, not implemented:** mTLS/workload identity —
|
||||
`docs/adr/ADR-019-identity-extension.md`.
|
||||
|
||||
### PDP / Policy Engine (EP-03)
|
||||
|
||||
**Responsibility:** decide ALLOW/DENY/REQUIRE_APPROVAL deterministically and auditably.
|
||||
Two-step model (`src/emcp_bus/pdp/client.py`, `PDPClient.evaluate`): (1) `entitlement.rego`
|
||||
decides whether the tool is in `ClientProfile.allowed_tools` — DENY here is definitive, no
|
||||
argument is evaluated; (2) `transaction.rego` restricts by argument/resource (value limit,
|
||||
region, client classification) and can downgrade an ALLOW to REQUIRE_APPROVAL above
|
||||
`approval_threshold`. Every response is a `PDPDecision` (`outcome`, `policy_version`,
|
||||
`reason_code`) — never a boolean. `policy_version` is a SHA-256 hash (12 chars) of the content of
|
||||
the `.rego` files on disk, computed once at Gateway boot.
|
||||
|
||||
**Validated against:** real OPA via subprocess (`opa run --server` binary), 9 Rego tests
|
||||
(`opa test`) + 6 Python integration tests.
|
||||
|
||||
### Global Capability Registry (EP-02)
|
||||
|
||||
**Responsibility:** a control-plane asset with its own authority and lifecycle over the
|
||||
capability catalog, separate from the execution view that the Gateway exposes to each client.
|
||||
`registry/models.py` defines `CapabilityManifest` (canonical name `<domain>.<capability>`,
|
||||
`risk_tier` R0–R4, `data_classification`, `side_effects`, `idempotent`, `entitlements`,
|
||||
`backend`, `approval`, `observability`, `lifecycle`). `registry/pipeline.py`
|
||||
(`PublishingPipeline`) is the sole entry path: it validates business gates (owner required,
|
||||
backend declared, tiers R3/R4 require `approval.required=true`) and advances
|
||||
`NOMINATE → REVIEW → REGISTER`; a separate `publish()` advances `REGISTER → ACTIVE`, when the
|
||||
capability becomes resolvable by the Fabric. `registry/lifecycle.py` mechanically ensures that
|
||||
nothing reaches `ACTIVE` without passing through `REVIEW`. `registry/store.py` persists to
|
||||
SQLite; `registry/seed.py` populates the Registry from `config/capabilities/*.yaml` at Gateway
|
||||
boot (one temporary file per process, not shared across runs).
|
||||
|
||||
**Validated against:** real SQLite, 9 example manifests (6 Sales + 3 Finance) going through the
|
||||
full pipeline without manual intervention.
|
||||
|
||||
### PEP / MCP Gateway (EP-05)
|
||||
|
||||
**Responsibility:** the single Policy Enforcement Point. `src/emcp_bus/gateway/server.py`
|
||||
implements two invariants: `tools/list` returns the intersection of the entitled backends'
|
||||
catalog with the authenticated client's `MaximumEntitlement` (filtered discovery); `tools/call`
|
||||
is reauthorized completely independently of what `tools/list` returned — the PDP is consulted on
|
||||
every call. Without a valid token: `tools/list` empty, `tools/call` always `Access denied:
|
||||
UNAUTHENTICATED`. An unreachable PDP is DENY (`PDP_UNAVAILABLE`), never implicit ALLOW. An
|
||||
`AuditSink` failure never becomes a 500 error on an already-decided operation (`_emit_audit`,
|
||||
swallows exceptions at all 8 emission points). Routing by capability is resolved on every call
|
||||
via `CapabilityRouter` (EP-06) against the Registry — a client can see tools from more than one
|
||||
backend (e.g., Sales + Finance). See [`interfaces/mcp-gateway.md`](interfaces/mcp-gateway.md) for
|
||||
the full contract (headers, denial codes, cache hint).
|
||||
|
||||
**Validated against:** real OPA, real Keycloak (Docker), real RS256 JWTs, and a real host MCP
|
||||
client against the containerized Gateway (`docker compose --profile core [--profile identity]
|
||||
up`).
|
||||
|
||||
### Enterprise MCP Fabric and example domain servers (EP-06)
|
||||
|
||||
**Responsibility:** `src/emcp_bus/fabric/router.py` (`CapabilityRouter`) resolves a
|
||||
`<domain>.<capability>` tool name to the backend that serves it, using only `ACTIVE` manifests
|
||||
from the Registry — never a static mapping in the Gateway. `services/example_mcp_servers/sales_domain/`
|
||||
(6 tools, R1+R2) and `finance_domain/` (3 tools: `invoice.get` R1, `payment.create` R2,
|
||||
`payment.execute` R3 with a full approval cycle) are the two example domains, protected by
|
||||
`BackendCredentialGate` (EP-05-T07) — a request without the Gateway's outbound credential never
|
||||
reaches a tool handler. `fabric/adapters/rest_adapter.py` (EP-06-T04) implements a generic REST
|
||||
adapter, tested standalone (`httpx.MockTransport`); no seed manifest uses
|
||||
`backend.type: "rest"`, so there is no live path through the Gateway exercising it.
|
||||
|
||||
**Validated against:** real routing Sales→sales-domain / Finance→finance-domain never crossed,
|
||||
including cross-container via real Docker Compose (network segmentation confirmed: none of the
|
||||
domain servers or OPA publishes a host port).
|
||||
|
||||
### Downstream / outbound identity (EP-07)
|
||||
|
||||
**Responsibility:** the credential the Fabric presents to the backend is never the inbound token
|
||||
of the MCP Client (`src/emcp_bus/downstream/identity.py`, `BackendCredentialProvider`). Two modes
|
||||
declared per backend in `config/backends/*.yaml` (`BackendConfig.credential_mode`):
|
||||
`service_account` (static credential read from an env var at call time — the default mode of the
|
||||
seed backends) and `token_exchange` (RFC 8693 via Keycloak Standard Token Exchange,
|
||||
`TokenExchangeClient` — two HTTP round-trips per new exchange, with a per-backend cache until
|
||||
shortly before `expires_in`). See [`interfaces/downstream-identity.md`](interfaces/downstream-identity.md).
|
||||
|
||||
**Validated against:** real Keycloak 26.7.3 — the exchange's `audience` must be an actually
|
||||
registered client id (a free-form string fails with "Audience not found"); requesting `audience`
|
||||
without a `scope` that effectively adds that audience fails with "Requested audience not
|
||||
available". The realm gained the `gateway-token-exchange` client and per-backend client scopes
|
||||
with `oidc-audience-mapper`. 7 tests (4 unit with `httpx.MockTransport`, 3 e2e against real
|
||||
Keycloak, including a full `tools/call` through the Gateway using a credential obtained via token
|
||||
exchange).
|
||||
|
||||
### Auditing, correlation and observability (EP-08)
|
||||
|
||||
**Responsibility:** `audit/models.py` defines `EventEnvelope` with a 3-category taxonomy adopted
|
||||
from the Agent Platform OCI reference (ADR-022): **IC** (Client Interaction/business journey,
|
||||
e.g. `client_authenticated`, `tool_call_allowed`), **NOC** (operational/availability error, e.g.
|
||||
`pdp_unavailable`), **GRL** (guardrail/governance, e.g. `tool_call_denied`,
|
||||
`tools_list_filtered`, `bypass_attempt_detected`). Every event recursively sanitizes any key
|
||||
whose name contains `token`/`secret`/`password`/`authorization`/`credential`, replacing the value
|
||||
with `sha256:<hex>` — a secret value never reaches a sink. `audit/correlation.py` propagates
|
||||
`decision_id`/`policy_decision_id`/`entitlement_version`/`policy_version`/`mcp_request_id` as
|
||||
first-class fields on every event and as OTel span attributes (ADR-017). `audit/sink.py`
|
||||
(`JSONLFileAuditSink`) writes to an append-only JSONL file. `common/otel.py` installs automatic
|
||||
tracing (`OpenTelemetryMiddleware`) with no manual span code in the Gateway. See
|
||||
[`interfaces/audit-events.md`](interfaces/audit-events.md).
|
||||
|
||||
**Validated against:** real `trace_id` correlation between distinct Docker containers (Gateway ↔
|
||||
domain server) for a successful call. Local `otel-collector` + Jaeger stack (EP-08-T04,
|
||||
`observability` profile): configuration validated (`docker compose config`), not exercised with
|
||||
real OTLP traffic by an automated test — Langfuse (EP-13-T02) is the one that actually received
|
||||
real OTLP traffic, validated manually (see the "Example agent" component below).
|
||||
|
||||
### Entitlement Manager (EP-04)
|
||||
|
||||
**Responsibility:** `entitlement/manager.py` (`EntitlementManager`) resolves the
|
||||
`MaximumEntitlement` by combining the `ClientProfile` (validated YAML, `entitlement/models.py`)
|
||||
with the PDP's entitlement decision, producing a versioned `ResolvedEntitlement`
|
||||
(`entitlement_version`) with `allowed_tools`/`risk_ceiling`/`profile_name`.
|
||||
`sync_profiles_to_pdp` pushes the validated profiles to OPA's data document at boot — a failure
|
||||
here prevents the Gateway from coming up "healthy" (fail-closed, README.md §38). Revocation
|
||||
invalidates the entitlement cache and re-evaluates starting from the next call.
|
||||
|
||||
**Validated against:** real OPA + real YAML; live policy revocation against a running Gateway
|
||||
(`tests/adversarial/test_protocol_identity.py`).
|
||||
|
||||
### Approval workflow (EP-14)
|
||||
|
||||
**Responsibility:** `approval/service.py` (`ApprovalService`) gates `REQUIRE_APPROVAL`
|
||||
decisions: `check_and_consume` (called by the Gateway on every attempt) only returns true for an
|
||||
approval that has been granted, is not expired, and has not yet been consumed — single-use,
|
||||
never "queue and release". An `ApprovalRequest` is bound to a SHA-256 hash of the exact triple
|
||||
`(client_profile, tool, arguments)` (`compute_operation_hash`), not just the tool name: an
|
||||
approval for one call never satisfies a different call with the same tool/profile.
|
||||
`check_and_consume` is protected by a `threading.Lock` (a real concurrency finding, see
|
||||
"Limitations" below). See [`interfaces/approval-workflow.md`](interfaces/approval-workflow.md).
|
||||
|
||||
**Validated against:** full e2e cycle for Sales and Finance (deny → approve externally → execute
|
||||
→ replay denied); replay under real concurrency (`ThreadPoolExecutor`, 20 threads).
|
||||
|
||||
### Profile Intelligence (EP-09)
|
||||
|
||||
**Responsibility:** `profile_intelligence/models.py` defines `ProfileView` (pseudonymized
|
||||
`subject_ref`, `profile_version`, `segments`, `attributes`, `reason_codes`, `expires_at`) as a
|
||||
generic, replaceable contract — any `ProfileIntelligenceProvider` (the deterministic rule-based
|
||||
stub in `rule_based_provider.py`, or a real clustering model in a production deployment) produces
|
||||
the same contract. A `ProfileView` is never consulted by the PDP and can never grant a capability
|
||||
outside the `MaximumEntitlement` — nothing here is connected to the authorization path.
|
||||
`governance.py` implements governance guardrails: pseudonymization, lineage logging, a drift
|
||||
monitor stub.
|
||||
|
||||
**Validated against:** determinism (same `subject_ref` + ISO window → same `profileVersion`).
|
||||
|
||||
### Offering Filter (EP-10)
|
||||
|
||||
**Responsibility:** `offering_filter/service.py` implements
|
||||
`FilteredOfferings = ActiveCatalog ∩ MaximumEntitlement ∩ ConsentContext`. The order of the
|
||||
intersection is deliberate: `allowed_tools` (MaximumEntitlement, already resolved by EP-04) is
|
||||
the only term treated as security-relevant and is applied first; a `ConsentContext` (here, just
|
||||
`allow_restricted_data` over `CapabilityManifest.data_classification`) can only reduce what has
|
||||
already survived that intersection, never reintroduce a capability outside the entitlement.
|
||||
|
||||
**Validated against:** subset invariant proven with Hypothesis, 2000 generated cases
|
||||
(`tests/unit/test_offering_filter_invariants.py`).
|
||||
|
||||
### Service Orchestrator (EP-11)
|
||||
|
||||
**Responsibility:** `orchestrator/graph.py` (`build_journey_graph`) is a real LangGraph
|
||||
`StateGraph` with fixed nodes `resolve_profile → resolve_entitlement → filter_offerings →
|
||||
decide_nba → approval_gate`, plus a dedicated re-entry for the recalculation loop after DENY
|
||||
(`handle_denial_node`, which never calls the Fabric directly — it only recalculates the NBA over
|
||||
the remaining offerings). `orchestrator/nba_model.py` defines `NBADecision`
|
||||
(`decision_id`, `action`, `capability_version`, `subject_ref`, `channel`, `agent`,
|
||||
`reason_codes`, `policy_context`, `expires_at`) and the `NBADecisionModel` protocol — swapping
|
||||
the reference implementation (`decisioning.RuleBasedNBADecisionModel`) for a ranking/AI model
|
||||
changes nothing in the input/output contract nor in what EP-03/04/05 authorize, because the model
|
||||
only orders/selects among `DecisionContext.filtered_offerings`, already intersected by EP-10
|
||||
before reaching here. `orchestrator/hitl_node.py` uses `langgraph.types.interrupt`/
|
||||
`Command(resume=...)` to genuinely pause the graph on approval — with no timeout that becomes an
|
||||
implicit ALLOW.
|
||||
|
||||
**Validated against:** real OPA + real LangGraph + real SQLite checkpointer, including proof that
|
||||
resuming from a paused checkpoint does not reprocess already-executed nodes.
|
||||
|
||||
### Agent Runtime (EP-12)
|
||||
|
||||
**Responsibility:** `agent_runtime/dispatcher.py` (`AgentDispatcher`) consumes an `NBADecision`
|
||||
and dispatches it against the real Gateway, authenticated as the MCP Client already registered
|
||||
for the target agent — never generating or storing its own credential (`AgentTokenProvider`,
|
||||
typically a real `client_credentials` against the same IdP as any other MCP Client).
|
||||
`handoff_graph.py` (EP-12-T03, `execute_handoff`/`build_handoff_payload`) implements handoff
|
||||
between agents with context minimization and entitlement resolution always independent per agent
|
||||
— see "Limitations" below for the finding that neither Mode A nor Mode B (EP-13-T05/T06) ended up
|
||||
using this primitive. `identity_resolver.py` (EP-12-T04, `IdentityResolver`) normalizes field
|
||||
names between channels (`config/orchestrator/identity.yaml`) and is never consulted by the PDP —
|
||||
checked mechanically via AST analysis in the corresponding unit test, the same discipline
|
||||
`global_supervisor.py` uses to prove it never imports `emcp_bus.orchestrator`.
|
||||
|
||||
**Validated against:** real Gateway + Keycloak — `AgentDispatcher` reaching the real Sales
|
||||
backend and being denied on cross-domain; two real agents obtaining independent
|
||||
`client_credentials` tokens.
|
||||
|
||||
### Example agent (EP-13)
|
||||
|
||||
**Responsibility:** `agents/example_agent/agent.py` is a conversational agent with real
|
||||
tool-calling against an OpenAI-compatible endpoint, whose catalog is restricted to the client's
|
||||
own `tools/list`. `common/pii_redaction.py` redacts PII/secrets before any export.
|
||||
`prompts/store.py` implements local prompt versioning (never overwrites a previous version) as
|
||||
a testable core — synchronization with Langfuse's Prompt Management API is a thin adapter that
|
||||
is documented, not implemented.
|
||||
|
||||
**Validated against:** a real LLM (`meta.llama-3.3-70b-instruct` via OCI Generative AI's
|
||||
OpenAI-compatible endpoint), with tool-calling empirically confirmed; the prompt injection
|
||||
scenario from README.md §29 (requesting a tool outside the entitlement) genuinely reproduced —
|
||||
the call never executes, either because the model never saw the tool in its own catalog, or
|
||||
because the Gateway/PDP denies it. Real self-hosted Langfuse (6 containers:
|
||||
web/worker/postgres/clickhouse/redis/minio, `deploy/langfuse/docker-compose.override.yml`)
|
||||
receiving real OTLP traffic from the agent — MCP spans and "generation" spans (with real
|
||||
`gen_ai.request.model`/tokens) physically confirmed in ClickHouse's `events_core` table. This
|
||||
validation is manual, not an automated e2e test (cost of bringing up 6 containers on every suite
|
||||
run).
|
||||
|
||||
### Mode A — external Global Supervisor (EP-13-T05, ADR-024)
|
||||
|
||||
**Responsibility:** `agent_runtime/global_supervisor.py` (`GlobalSupervisor`) is the deliberately
|
||||
simple stand-in (keyword-based routing) for an external orchestration platform that decides
|
||||
which backend agent handles a conversation — it never is, nor claims to be, the security
|
||||
boundary; it never imports `emcp_bus.orchestrator` (checked via AST). The real security property
|
||||
does not live in this module: it lies in the fact that each `BackendAgent`
|
||||
(`agents/example_agent/backends/{sales_agent,finance_agent}/`) resolves its own MCP
|
||||
Client/entitlement completely independently — its own token, `tools/list`, and reauthorization —
|
||||
and the `GlobalSupervisor` never reads, stores, or passes a credential from one backend to
|
||||
another.
|
||||
|
||||
**Validated against:** real LLM + real Gateway (`tests/e2e/test_multi_agent_mode_a.py`, 2 tests):
|
||||
handoff from `sales_agent` to `finance_agent` with a completely distinct token; a prompt
|
||||
injection via handoff attempting `finance.payment.execute` is still denied by the real
|
||||
Gateway/PDP.
|
||||
|
||||
### Mode B — Orchestrator as the sole NBA/NBO brain (EP-13-T06, `could`, ADR-024)
|
||||
|
||||
**Responsibility:** `agents/example_agent/mode_b_orchestrator_driven/orchestrator.py`
|
||||
(`ModeBOrchestrator`) invokes the real EP-11-T02 journey `StateGraph` (unmodified) once per
|
||||
relevant `client_id` for the same subject, combines the resulting `NBADecision`s (reference rule:
|
||||
a pending Finance item always beats a discretionary Sales offer), and dispatches the winner via
|
||||
the real `AgentDispatcher` — never creating its own authorization path. It deliberately does not
|
||||
extend `JourneyState`/`build_journey_graph` to handle multiple `client_id`s natively; the
|
||||
combination lives a layer above the graph (a design decision recorded in the module itself).
|
||||
|
||||
**Validated against:** real Keycloak + real OPA + real Sales/Finance
|
||||
(`tests/e2e/test_real_keycloak.py::test_mode_b_orchestrator_combines_offerings_and_dispatches_through_the_real_chain`):
|
||||
candidates from both domains genuinely combined, final decision from our Orchestrator, `ALLOW`
|
||||
dispatch end-to-end.
|
||||
|
||||
## What was validated, by real dependency
|
||||
|
||||
| Real dependency | What it exercises | Where |
|
||||
|---|---|---|
|
||||
| OPA (subprocess) | `entitlement.rego`/`transaction.rego`, `PDPClient` | `tests/unit/policies/`, the whole e2e suite |
|
||||
| Keycloak (Docker, 26.7.3) | client_credentials, real JWKS, RFC 8693 token exchange, Mode B | `tests/e2e/test_real_keycloak.py` |
|
||||
| LangGraph 0.6 + SQLite checkpointer | journey graph, approval pause/resume | `tests/unit/test_orchestrator_graph.py`, M3 e2e |
|
||||
| Real LLM (OpenAI-compatible) | example agent tool-calling, prompt injection | `tests/e2e/test_example_agent.py`, `test_multi_agent_mode_a.py` |
|
||||
| Full Docker Compose | build+up+fail-closed+cross-container trace+network segmentation | `tests/e2e/test_security_acceptance.py`, documented manual validation |
|
||||
| Self-hosted Langfuse (6 containers) | real OTLP export, generation spans | Manual validation (not automated) |
|
||||
|
||||
## Limitations and real architectural findings
|
||||
|
||||
- **`ttl_ms` of the `tools/list` cache hint (SEP-2549, EP-05-T03)**: correctly implemented in
|
||||
`gateway/cache.py`, but the field belongs to the 2026-07-28 protocol, which only exists in the
|
||||
`mcp` SDK's *stateless per-request* mode (no `initialize` handshake). This RI uses the classic
|
||||
session handshake everywhere (Gateway↔Client and Gateway↔backends), so `ttl_ms` never reaches
|
||||
the wire in this architecture — the SDK drops the field during serialization for protocol
|
||||
versions before 2026-07-28 (verified empirically). `cache_scope="private"` is always set, but
|
||||
since it does not depend on the wire-mode it is also not something an e2e test can verify in a
|
||||
discriminating way. The real security property (never leaking the catalog between clients) is
|
||||
guaranteed independently: the Gateway never caches the filtered catalog server-side.
|
||||
- **Header/body consistency (EP-05-T08)**: implemented as its own gate
|
||||
(`gateway/canonical_request.py`) reusing the SDK's field constants, not as the SDK's native
|
||||
validation ladder (which only applies to the same 2026-07-28 *stateless per-request* mode
|
||||
above) — for the same reason, this RI does not reach it via the native path. Missing headers
|
||||
(a client that does not implement this SEP) are not an error.
|
||||
- **REST adapter (EP-06-T04)**: implemented and tested standalone
|
||||
(`fabric/adapters/rest_adapter.py`); no example backend in this RI is REST (both are
|
||||
MCP-native), so live routing through the Gateway to a `type: "rest"` backend was never
|
||||
exercised end-to-end.
|
||||
- **Handoff between agents (EP-12-T03, `execute_handoff`/`build_handoff_payload`)**: remains
|
||||
implemented and tested (`tests/unit/test_agent_runtime_handoff.py`), but neither Mode A
|
||||
(EP-13-T05) nor Mode B (EP-13-T06) ended up using this primitive. Mode A needs conversational
|
||||
routing (which backend responds to the next message), not dispatch of a specific `NBADecision`
|
||||
— `GlobalSupervisor` calls `run_turn()` directly on the backends. Mode B already combines
|
||||
`NBADecision`s from multiple clients in a single step and dispatches the winner via plain
|
||||
`AgentDispatcher.dispatch` — there is no "origin" agent handing control to another for
|
||||
`execute_handoff` to intercept. The primitive remains correct and reusable for a future
|
||||
handoff scenario *within* an Orchestrator-driven journey, but that specific usage form does not
|
||||
exist in this RI.
|
||||
- **Request replay**: no nonce/anti-replay in this RI — a captured request with a still-valid
|
||||
token can be resent successfully. Mitigated by short-lived tokens
|
||||
(`credential_policy.short_lived` on every seed `ClientProfile`) and by complete auditing (every
|
||||
attempt, even an identical one, generates its own `decision_id`). See
|
||||
`tests/adversarial/test_protocol_identity.py`.
|
||||
- **Rate limiting / bulk exfiltration**: no request rate limiting in the Gateway — N consecutive
|
||||
legitimate reads are all allowed. Mitigated only by after-the-fact detectability via auditing
|
||||
(every call generates a correlated event), never by real-time prevention.
|
||||
- **Real race condition fixed in `ApprovalService`**: `check_and_consume` did a check-then-set in
|
||||
two non-atomic operations — under real thread concurrency, two attempts could both observe
|
||||
"granted" before either wrote "consumed" (double-spend of a single-use grant). Fixed with a
|
||||
dedicated `threading.Lock`, proven with `ThreadPoolExecutor` (20 threads) in
|
||||
`tests/adversarial/test_protocol_identity.py`.
|
||||
- **Audit sink failure could become a real 500 error, fixed**: none of the 8
|
||||
`audit_sink.emit(...)` calls in the Gateway were protected — a sink failure (full disk,
|
||||
unavailable queue) could turn an already-`ALLOW`ed operation into a 500 error for the caller,
|
||||
violating the policy of never blocking low-risk operations. Fixed with the `_emit_audit` helper
|
||||
(`gateway/server.py`), which swallows any exception from the sink at all 8 emission points, for
|
||||
both ALLOW and DENY. A sink failure in production needs its own observability (see
|
||||
`Production-Recommendations.md`) — this behavior is correct by design, but silent.
|
||||
- **`GlobalSupervisor` routing (Mode A)**: simple keyword-based, deliberately not the security
|
||||
boundary nor a claim of routing quality — just an honest stand-in for what a real external
|
||||
platform would do.
|
||||
- **`ModeBOrchestrator` combination rule (Mode B)**: "Finance always beats Sales" is a simple
|
||||
reference rule, not a claim of optimality — the same posture as `RuleBasedNBADecisionModel`
|
||||
(EP-11-T03).
|
||||
- **Observability stack (EP-08-T04)**: configuration validated (`docker compose config`), not
|
||||
exercised with real traffic by an automated test (the full startup with the `observability`
|
||||
profile was validated manually).
|
||||
394
RI/docs/AS-BUILT.md
Normal file
394
RI/docs/AS-BUILT.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# AS-BUILT — Enterprise MCP Service Bus (RI)
|
||||
|
||||
Referência técnica do que existe hoje na Reference Implementation (RI): responsabilidade de
|
||||
cada componente, onde o código vive, contratos de entrada/saída em nível alto (o detalhe fino
|
||||
de cada contrato está em [`interfaces/`](interfaces/)), o que foi validado e contra qual
|
||||
dependência real, e as limitações arquiteturais reais encontradas durante a implementação.
|
||||
|
||||
Público-alvo: quem vai **integrar com ou estender** a RI. Para quem vai **rodar** a RI, ver
|
||||
[`How-To.md`](How-To.md). Para quem vai **avaliar/adaptar** a RI para um cenário real, ver
|
||||
[`Assumptions.md`](Assumptions.md) e [`Production-Recommendations.md`](Production-Recommendations.md).
|
||||
|
||||
Este documento sucede o backlog de planejamento `docs/RI-PLANNING.md` (removido do
|
||||
repositório após a conclusão do marco M4, com histórico preservado no git) — a informação
|
||||
factual desse backlog que ainda é relevante para quem usa o produto acabado foi migrada para
|
||||
cá, para `Assumptions.md` e para `Production-Recommendations.md`.
|
||||
|
||||
## Status
|
||||
|
||||
Todo o backlog planejado (marcos M0 a M4) está implementado, testado e validado. **261 testes
|
||||
automatizados** (contagem verificada via `pytest --collect-only` nesta revisão): 254 passam com
|
||||
`pytest --forked` (unit + e2e + contract + adversarial), mais 7
|
||||
testes de aceite de segurança (`tests/e2e/test_security_acceptance.py`) que exigem Docker real e
|
||||
são rodados separadamente pelo custo de build+Keycloak por execução. Lógica de produção sem
|
||||
mocks no núcleo de segurança e orquestração: OPA real (subprocess), Keycloak real (Docker),
|
||||
LangGraph real com checkpointer SQLite, um LLM real (endpoint OpenAI-compatible), JWKS HTTP real
|
||||
(stand-in leve para o resto da suíte e2e) com JWT RS256 reais, YAML reais de `config/`, SQLite
|
||||
real para o Registry.
|
||||
|
||||
## Índice de épicos
|
||||
|
||||
Cada `EP-XX-TXX` que aparece em comentários/docstrings do código-fonte referencia um destes
|
||||
épicos. Esta tabela é o que substitui `docs/RI-PLANNING.md` §8.6 como fonte de contexto para
|
||||
essas referências.
|
||||
|
||||
| Épico | Título | Módulo/teste principal |
|
||||
|---|---|---|
|
||||
| EP-00 | Bootstrap e tooling da RI | `pyproject.toml`, `Makefile`, `.github/workflows/ci.yml`, `src/emcp_bus/common/config.py` |
|
||||
| EP-01 | Identidade do MCP Client e autenticação | `src/emcp_bus/identity/*.py`, `deploy/keycloak/realm-export.json` |
|
||||
| EP-02 | Global Capability Registry e publishing pipeline | `src/emcp_bus/registry/*.py` |
|
||||
| EP-03 | PDP / Policy Engine (OPA) | `src/emcp_bus/pdp/*.py`, `config/policies/*.rego` |
|
||||
| EP-04 | Entitlement Manager | `src/emcp_bus/entitlement/*.py` |
|
||||
| EP-05 | MCP Gateway / PEP | `src/emcp_bus/gateway/*.py` |
|
||||
| EP-06 | Enterprise MCP Fabric e servidores de domínio de exemplo | `src/emcp_bus/fabric/*.py`, `services/example_mcp_servers/*` |
|
||||
| EP-07 | Downstream identity (inbound vs outbound) | `src/emcp_bus/downstream/*.py` |
|
||||
| EP-08 | Auditoria, correlação e observabilidade | `src/emcp_bus/audit/*.py`, `src/emcp_bus/common/otel.py` |
|
||||
| EP-09 | Profile Intelligence (contrato genérico) | `src/emcp_bus/profile_intelligence/*.py` |
|
||||
| EP-10 | Offering Filter | `src/emcp_bus/offering_filter/service.py` |
|
||||
| EP-11 | Service Orchestrator (NBA/NBO) | `src/emcp_bus/orchestrator/*.py` |
|
||||
| EP-12 | Agent Runtime e channel adapters | `src/emcp_bus/agent_runtime/*.py` |
|
||||
| EP-13 | Agente de exemplo e instrumentação Langfuse | `agents/example_agent/*` |
|
||||
| EP-14 | Human-in-the-loop / Approval workflow | `src/emcp_bus/approval/*.py` |
|
||||
| EP-15 | Testes de segurança e adversariais | `tests/contract/`, `tests/e2e/test_security_acceptance.py`, `tests/adversarial/`, `tests/e2e/test_fail_closed.py` |
|
||||
| EP-16 | Deploy final e documentação | `docker-compose.yml`, `docs/adr/` |
|
||||
|
||||
Dentro de cada épico, `TXX` numera as tarefas (ex.: `EP-05-T03` = terceira tarefa do épico
|
||||
EP-05/Gateway — o cache hint de `tools/list`). Os ADRs referenciados por número (`ADR-XXX`) são
|
||||
arquivos individuais em [`../../docs/adr/`](../../docs/adr/).
|
||||
|
||||
## Componentes
|
||||
|
||||
### Identidade do MCP Client (EP-01)
|
||||
|
||||
**Responsabilidade:** autenticar a identidade de workload que faz a chamada, nunca a identidade
|
||||
autodeclarada em `clientInfo` ou em qualquer campo do payload. `src/emcp_bus/identity/authn.py`
|
||||
(`TokenValidator`) valida um bearer token OAuth2/OIDC contra o JWKS do IdP (assinatura RS256,
|
||||
`exp`, `iss`, `aud`); o `client_id` confiável vem exclusivamente da claim `azp`/`client_id` do
|
||||
token verificado. `src/emcp_bus/identity/models.py` (`ClientRegistration`) mapeia esse
|
||||
`client_id` a exatamente um `ClientProfile` (EP-04) via YAML em `config/clients/*.yaml`.
|
||||
`shared_client_lint.py` (EP-01-T05) valida em CI que dois agentes distintos nunca compartilham
|
||||
o mesmo `client_id` sob profiles diferentes.
|
||||
|
||||
**Validado contra:** Keycloak 26.7.3 real via Docker (`tests/e2e/test_real_keycloak.py`) — realm
|
||||
`emcp` importado de `deploy/keycloak/realm-export.json`, 3 clients (`sales-read-agent`,
|
||||
`sales-write-agent`, `finance-payments-agent`) com grant `client_credentials`; e um servidor JWKS
|
||||
HTTP local mais leve como stand-in para o resto da suíte e2e (válido para a lógica de validação,
|
||||
não para a integração de protocolo real com o IdP).
|
||||
|
||||
**Extensão de produção documentada, não implementada:** mTLS/workload identity —
|
||||
`docs/adr/ADR-019-identity-extension.md`.
|
||||
|
||||
### PDP / Policy Engine (EP-03)
|
||||
|
||||
**Responsabilidade:** decidir ALLOW/DENY/REQUIRE_APPROVAL de forma determinística e auditável.
|
||||
Modelo em dois passos (`src/emcp_bus/pdp/client.py`, `PDPClient.evaluate`): (1) `entitlement.rego`
|
||||
decide se a tool está no `ClientProfile.allowed_tools` — DENY aqui é definitivo, nenhum argumento
|
||||
é avaliado; (2) `transaction.rego` restringe por argumento/recurso (limite de valor, região,
|
||||
classificação do cliente) e pode rebaixar um ALLOW para REQUIRE_APPROVAL acima de
|
||||
`approval_threshold`. Toda resposta é um `PDPDecision` (`outcome`, `policy_version`,
|
||||
`reason_code`) — nunca um booleano. `policy_version` é um hash SHA-256 (12 chars) do conteúdo
|
||||
dos arquivos `.rego` em disco, computado uma vez no boot do Gateway.
|
||||
|
||||
**Validado contra:** OPA real via subprocess (binário `opa run --server`), 9 testes Rego
|
||||
(`opa test`) + 6 testes Python de integração.
|
||||
|
||||
### Global Capability Registry (EP-02)
|
||||
|
||||
**Responsabilidade:** control-plane asset com autoridade e ciclo de vida próprios sobre o
|
||||
catálogo de capabilities, separado da visão de execução que o Gateway expõe a cada client.
|
||||
`registry/models.py` define `CapabilityManifest` (nome canônico `<domínio>.<capability>`,
|
||||
`risk_tier` R0–R4, `data_classification`, `side_effects`, `idempotent`, `entitlements`,
|
||||
`backend`, `approval`, `observability`, `lifecycle`). `registry/pipeline.py`
|
||||
(`PublishingPipeline`) é o único caminho de entrada: valida gates de negócio (owner obrigatório,
|
||||
backend declarado, tiers R3/R4 exigem `approval.required=true`) e avança
|
||||
`NOMINATE → REVIEW → REGISTER`; um `publish()` separado avança `REGISTER → ACTIVE`, quando a
|
||||
capability se torna resolvível pelo Fabric. `registry/lifecycle.py` garante mecanicamente que
|
||||
nada alcança `ACTIVE` sem passar por `REVIEW`. `registry/store.py` persiste em SQLite;
|
||||
`registry/seed.py` popula o Registry a partir de `config/capabilities/*.yaml` no boot do Gateway
|
||||
(um arquivo temporário por processo, não compartilhado entre execuções).
|
||||
|
||||
**Validado contra:** SQLite real, 9 manifestos de exemplo (6 Sales + 3 Finance) passando pelo
|
||||
pipeline completo sem intervenção manual.
|
||||
|
||||
### PEP / MCP Gateway (EP-05)
|
||||
|
||||
**Responsabilidade:** único Policy Enforcement Point. `src/emcp_bus/gateway/server.py`
|
||||
implementa dois invariantes: `tools/list` retorna a interseção do catálogo dos backends
|
||||
entitled com o `MaximumEntitlement` do client autenticado (discovery filtrado); `tools/call` é
|
||||
reautorizado de forma totalmente independente do que `tools/list` retornou — o PDP é consultado
|
||||
em toda chamada. Sem token válido: `tools/list` vazio, `tools/call` sempre `Access denied:
|
||||
UNAUTHENTICATED`. PDP inalcançável é DENY (`PDP_UNAVAILABLE`), nunca ALLOW implícito. Falha do
|
||||
`AuditSink` nunca vira erro 500 numa operação já decidida (`_emit_audit`, engole exceção em
|
||||
todos os 8 pontos de emissão). Roteamento por capability é resolvido a cada chamada via
|
||||
`CapabilityRouter` (EP-06) contra o Registry — um client pode ver tools de mais de um backend
|
||||
(ex.: Sales + Finance). Ver [`interfaces/mcp-gateway.md`](interfaces/mcp-gateway.md) para o
|
||||
contrato completo (headers, códigos de negação, cache hint).
|
||||
|
||||
**Validado contra:** OPA real, Keycloak real (Docker), JWT RS256 reais, e um cliente MCP real do
|
||||
host contra o Gateway containerizado (`docker compose --profile core [--profile identity] up`).
|
||||
|
||||
### Enterprise MCP Fabric e servidores de domínio de exemplo (EP-06)
|
||||
|
||||
**Responsabilidade:** `src/emcp_bus/fabric/router.py` (`CapabilityRouter`) resolve um nome de
|
||||
tool `<domínio>.<capability>` para o backend que a serve, usando apenas manifestos `ACTIVE` do
|
||||
Registry — nunca um mapeamento estático no Gateway. `services/example_mcp_servers/sales_domain/`
|
||||
(6 tools, R1+R2) e `finance_domain/` (3 tools: `invoice.get` R1, `payment.create` R2,
|
||||
`payment.execute` R3 com ciclo de aprovação completo) são os dois domínios de exemplo,
|
||||
protegidos por `BackendCredentialGate` (EP-05-T07) — uma requisição sem a credencial de saída do
|
||||
Gateway nunca alcança um handler de tool. `fabric/adapters/rest_adapter.py` (EP-06-T04)
|
||||
implementa um adapter REST genérico, testado de forma standalone (`httpx.MockTransport`); nenhum
|
||||
manifesto seed usa `backend.type: "rest"`, então não há caminho ao vivo através do Gateway
|
||||
exercitando-o.
|
||||
|
||||
**Validado contra:** roteamento real Sales→sales-domain / Finance→finance-domain nunca cruzado,
|
||||
inclusive cross-container via Docker Compose real (segmentação de rede confirmada: nenhum dos
|
||||
domain servers ou o OPA publica porta de host).
|
||||
|
||||
### Downstream / outbound identity (EP-07)
|
||||
|
||||
**Responsabilidade:** a credencial que o Fabric apresenta ao backend nunca é o token inbound do
|
||||
MCP Client (`src/emcp_bus/downstream/identity.py`, `BackendCredentialProvider`). Dois modos
|
||||
declarados por backend em `config/backends/*.yaml` (`BackendConfig.credential_mode`):
|
||||
`service_account` (credencial estática lida de uma env var no momento da chamada — o modo
|
||||
padrão dos backends seed) e `token_exchange` (RFC 8693 via Keycloak Standard Token Exchange,
|
||||
`TokenExchangeClient` — dois round-trips HTTP por troca nova, com cache por backend até pouco
|
||||
antes do `expires_in`). Ver [`interfaces/downstream-identity.md`](interfaces/downstream-identity.md).
|
||||
|
||||
**Validado contra:** Keycloak 26.7.3 real — `audience` do exchange precisa ser um client id
|
||||
realmente registrado (uma string livre falha com "Audience not found"); pedir `audience` sem um
|
||||
`scope` que efetivamente adiciona essa audience falha com "Requested audience not available".
|
||||
Realm ganhou o client `gateway-token-exchange` e client scopes com `oidc-audience-mapper` por
|
||||
backend. 7 testes (4 unit com `httpx.MockTransport`, 3 e2e contra Keycloak real, incluindo um
|
||||
`tools/call` completo pelo Gateway usando uma credencial obtida por token exchange).
|
||||
|
||||
### Auditoria, correlação e observabilidade (EP-08)
|
||||
|
||||
**Responsabilidade:** `audit/models.py` define `EventEnvelope` com uma taxonomia de 3 categorias
|
||||
adotada da referência Agent Platform OCI (ADR-022): **IC** (Interação de Cliente/negócio-jornada,
|
||||
ex. `client_authenticated`, `tool_call_allowed`), **NOC** (operacional/erro de disponibilidade,
|
||||
ex. `pdp_unavailable`), **GRL** (guardrail/governança, ex. `tool_call_denied`,
|
||||
`tools_list_filtered`, `bypass_attempt_detected`). Todo evento sanitiza recursivamente qualquer
|
||||
chave cujo nome contenha `token`/`secret`/`password`/`authorization`/`credential`, substituindo o
|
||||
valor por `sha256:<hex>` — nunca um valor de segredo chega a um sink. `audit/correlation.py`
|
||||
propaga `decision_id`/`policy_decision_id`/`entitlement_version`/`policy_version`/
|
||||
`mcp_request_id` como campos de primeira classe em todo evento e como atributos de span OTel
|
||||
(ADR-017). `audit/sink.py` (`JSONLFileAuditSink`) grava em arquivo JSONL append-only.
|
||||
`common/otel.py` instala tracing automático (`OpenTelemetryMiddleware`) sem código manual de
|
||||
span no Gateway. Ver [`interfaces/audit-events.md`](interfaces/audit-events.md).
|
||||
|
||||
**Validado contra:** correlação de `trace_id` real entre containers Docker distintos (Gateway ↔
|
||||
domain server) para uma chamada bem-sucedida. Stack local `otel-collector` + Jaeger
|
||||
(EP-08-T04, perfil `observability`): configuração validada (`docker compose config`), não
|
||||
exercitada com tráfego OTLP real por um teste automatizado — Langfuse (EP-13-T02) é quem recebeu
|
||||
tráfego OTLP real de fato, validado manualmente (ver componente "Agente de exemplo" abaixo).
|
||||
|
||||
### Entitlement Manager (EP-04)
|
||||
|
||||
**Responsabilidade:** `entitlement/manager.py` (`EntitlementManager`) resolve o
|
||||
`MaximumEntitlement` combinando o `ClientProfile` (YAML validado, `entitlement/models.py`) com a
|
||||
decisão de entitlement do PDP, produzindo um `ResolvedEntitlement` versionado
|
||||
(`entitlement_version`) com `allowed_tools`/`risk_ceiling`/`profile_name`. `sync_profiles_to_pdp`
|
||||
empurra os profiles validados para o data document do OPA no boot — falha aqui impede o Gateway
|
||||
de subir "saudável" (fail-closed, README.md §38). Revogação invalida o cache de entitlement e
|
||||
reavalia a partir da próxima chamada.
|
||||
|
||||
**Validado contra:** OPA real + YAML reais; revogação de política ao vivo contra um Gateway
|
||||
rodando (`tests/adversarial/test_protocol_identity.py`).
|
||||
|
||||
### Approval workflow (EP-14)
|
||||
|
||||
**Responsabilidade:** `approval/service.py` (`ApprovalService`) gate para decisões
|
||||
`REQUIRE_APPROVAL`: `check_and_consume` (chamado pelo Gateway a cada tentativa) só retorna
|
||||
verdadeiro para uma aprovação concedida, não expirada e ainda não consumida — single-use, nunca
|
||||
"enfileira e libera". Um `ApprovalRequest` é vinculado a um hash SHA-256 do triplo exato
|
||||
`(client_profile, tool, arguments)` (`compute_operation_hash`), não apenas ao nome da tool: uma
|
||||
aprovação para uma chamada nunca satisfaz uma chamada diferente com os mesmos tool/profile.
|
||||
`check_and_consume` é protegido por `threading.Lock` (achado real de concorrência, ver
|
||||
"Limitações" abaixo). Ver [`interfaces/approval-workflow.md`](interfaces/approval-workflow.md).
|
||||
|
||||
**Validado contra:** ciclo completo e2e para Sales e Finance (nega → aprova externamente →
|
||||
executa → replay nega); replay sob concorrência real (`ThreadPoolExecutor`, 20 threads).
|
||||
|
||||
### Profile Intelligence (EP-09)
|
||||
|
||||
**Responsabilidade:** `profile_intelligence/models.py` define `ProfileView` (`subject_ref`
|
||||
pseudonimizado, `profile_version`, `segments`, `attributes`, `reason_codes`, `expires_at`) como
|
||||
um contrato genérico e substituível — qualquer `ProfileIntelligenceProvider` (o stub
|
||||
determinístico rule-based em `rule_based_provider.py`, ou um modelo de clustering real numa
|
||||
implantação de produção) produz o mesmo contrato. Um `ProfileView` nunca é consultado pelo PDP e
|
||||
nunca pode conceder uma capability fora do `MaximumEntitlement` — nada aqui está conectado ao
|
||||
caminho de autorização. `governance.py` implementa os guardrails de governança: pseudonimização,
|
||||
log de linhagem, stub de monitor de drift.
|
||||
|
||||
**Validado contra:** determinismo (mesmo `subject_ref` + janela ISO → mesmo `profileVersion`).
|
||||
|
||||
### Offering Filter (EP-10)
|
||||
|
||||
**Responsabilidade:** `offering_filter/service.py` implementa
|
||||
`FilteredOfferings = ActiveCatalog ∩ MaximumEntitlement ∩ ConsentContext`. A ordem da interseção
|
||||
é deliberada: `allowed_tools` (MaximumEntitlement, já resolvido pelo EP-04) é o único termo
|
||||
tratado como relevante para segurança e é aplicado primeiro; um `ConsentContext` (aqui, apenas
|
||||
`allow_restricted_data` sobre `CapabilityManifest.data_classification`) só pode reduzir o que já
|
||||
sobreviveu a essa interseção, nunca reintroduzir uma capability fora do entitlement.
|
||||
|
||||
**Validado contra:** invariante de subconjunto provada com Hypothesis, 2000 casos gerados
|
||||
(`tests/unit/test_offering_filter_invariants.py`).
|
||||
|
||||
### Service Orchestrator (EP-11)
|
||||
|
||||
**Responsabilidade:** `orchestrator/graph.py` (`build_journey_graph`) é um `StateGraph` real do
|
||||
LangGraph com nós fixos `resolve_profile → resolve_entitlement → filter_offerings → decide_nba
|
||||
→ approval_gate`, mais uma reentrada dedicada para o loop de recálculo após DENY
|
||||
(`handle_denial_node`, que nunca chama o Fabric diretamente — apenas recalcula a NBA sobre as
|
||||
offerings restantes). `orchestrator/nba_model.py` define `NBADecision`
|
||||
(`decision_id`, `action`, `capability_version`, `subject_ref`, `channel`, `agent`,
|
||||
`reason_codes`, `policy_context`, `expires_at`) e o protocolo `NBADecisionModel` — trocar a
|
||||
implementação de referência (`decisioning.RuleBasedNBADecisionModel`) por um modelo de
|
||||
ranking/IA não muda nada no contrato de entrada/saída nem no que EP-03/04/05 autorizam, porque o
|
||||
modelo só ordena/seleciona entre `DecisionContext.filtered_offerings`, já intersectado pelo
|
||||
EP-10 antes de chegar aqui. `orchestrator/hitl_node.py` usa `langgraph.types.interrupt`/
|
||||
`Command(resume=...)` para pausar de verdade o grafo em aprovação — sem timeout que vire ALLOW
|
||||
implícito.
|
||||
|
||||
**Validado contra:** OPA real + LangGraph real + checkpointer SQLite real, incluindo prova de
|
||||
que retomar de um checkpoint pausado não reprocessa nós já executados.
|
||||
|
||||
### Agent Runtime (EP-12)
|
||||
|
||||
**Responsabilidade:** `agent_runtime/dispatcher.py` (`AgentDispatcher`) consome uma `NBADecision`
|
||||
e a despacha contra o Gateway real, autenticado como o MCP Client já registrado para o agente
|
||||
alvo — nunca gera ou armazena credencial própria (`AgentTokenProvider`, tipicamente um
|
||||
`client_credentials` real contra o mesmo IdP de qualquer outro MCP Client). `handoff_graph.py`
|
||||
(EP-12-T03, `execute_handoff`/`build_handoff_payload`) implementa handoff entre agentes com
|
||||
minimização de contexto e resolução de entitlement sempre independente por agente — ver
|
||||
"Limitações" abaixo para o achado de que nem o Modo A nem o Modo B (EP-13-T05/T06) acabaram
|
||||
usando esse primitivo. `identity_resolver.py` (EP-12-T04, `IdentityResolver`) normaliza nomes de
|
||||
campo entre canais (`config/orchestrator/identity.yaml`) e nunca é consultado pelo PDP — checado
|
||||
mecanicamente via análise de AST no teste unitário correspondente, a mesma disciplina que
|
||||
`global_supervisor.py` usa para provar que nunca importa `emcp_bus.orchestrator`.
|
||||
|
||||
**Validado contra:** Gateway + Keycloak reais — `AgentDispatcher` alcançando o backend Sales
|
||||
real e sendo negado no cross-domain; dois agentes reais obtendo tokens `client_credentials`
|
||||
independentes.
|
||||
|
||||
### Agente de exemplo (EP-13)
|
||||
|
||||
**Responsabilidade:** `agents/example_agent/agent.py` é um agente conversacional com
|
||||
tool-calling real contra um endpoint OpenAI-compatible, cujo catálogo é restrito ao próprio
|
||||
`tools/list` do client. `common/pii_redaction.py` redige PII/segredos antes de qualquer export.
|
||||
`prompts/store.py` implementa versionamento local de prompt (nunca sobrescreve uma versão
|
||||
anterior) como núcleo testável — sincronização com a API de Prompt Management do Langfuse é um
|
||||
adapter fino documentado, não implementado.
|
||||
|
||||
**Validado contra:** um LLM real (`meta.llama-3.3-70b-instruct` via endpoint OpenAI-compatible da
|
||||
OCI Generative AI), com tool-calling confirmado empiricamente; o cenário de prompt injection do
|
||||
README.md §29 (pedir uma tool fora do entitlement) reproduzido de verdade — a chamada nunca
|
||||
executa, seja porque o modelo nunca viu a tool no próprio catálogo, seja porque o Gateway/PDP
|
||||
nega. Langfuse self-hosted real (6 containers: web/worker/postgres/clickhouse/redis/minio,
|
||||
`deploy/langfuse/docker-compose.override.yml`) recebendo tráfego OTLP real do agente —
|
||||
spans de MCP e de "generation" (com `gen_ai.request.model`/tokens reais) confirmados fisicamente
|
||||
na tabela `events_core` do ClickHouse. Essa validação é manual, não um teste e2e automatizado
|
||||
(custo de subir 6 containers a cada execução da suíte).
|
||||
|
||||
### Modo A — Global Supervisor externo (EP-13-T05, ADR-024)
|
||||
|
||||
**Responsabilidade:** `agent_runtime/global_supervisor.py` (`GlobalSupervisor`) é o stand-in
|
||||
deliberadamente simples (roteamento por palavra-chave) para uma plataforma de orquestração
|
||||
externa que decide qual agente de backend atende uma conversa — nunca é, ou pretende ser, a
|
||||
fronteira de segurança; nunca importa `emcp_bus.orchestrator` (checado via AST). A propriedade de
|
||||
segurança real não está neste módulo: está no fato de que cada `BackendAgent`
|
||||
(`agents/example_agent/backends/{sales_agent,finance_agent}/`) resolve seu próprio MCP
|
||||
Client/entitlement de forma completamente independente — token, `tools/list` e reautorização
|
||||
próprios — e o `GlobalSupervisor` nunca lê, guarda ou repassa a credencial de um backend para
|
||||
outro.
|
||||
|
||||
**Validado contra:** LLM real + Gateway real (`tests/e2e/test_multi_agent_mode_a.py`, 2 testes):
|
||||
handoff de `sales_agent` para `finance_agent` com token completamente distinto; injeção de
|
||||
prompt via handoff tentando `finance.payment.execute` continua negada pelo Gateway/PDP reais.
|
||||
|
||||
### Modo B — Orchestrator como único cérebro de NBA/NBO (EP-13-T06, `could`, ADR-024)
|
||||
|
||||
**Responsabilidade:** `agents/example_agent/mode_b_orchestrator_driven/orchestrator.py`
|
||||
(`ModeBOrchestrator`) invoca o journey `StateGraph` real do EP-11-T02 (não modificado) uma vez
|
||||
por `client_id` relevante para o mesmo subject, combina os `NBADecision`s resultantes (regra de
|
||||
referência: pendência de Finance sempre vence oferta discricionária de Sales) e despacha o
|
||||
vencedor via `AgentDispatcher` real — nunca cria um caminho de autorização próprio. Deliberadamente
|
||||
não estende `JourneyState`/`build_journey_graph` para múltiplos `client_id`s nativamente; a
|
||||
combinação vive uma camada acima do grafo (decisão de design registrada no próprio módulo).
|
||||
|
||||
**Validado contra:** Keycloak real + OPA real + Sales/Finance reais
|
||||
(`tests/e2e/test_real_keycloak.py::test_mode_b_orchestrator_combines_offerings_and_dispatches_through_the_real_chain`):
|
||||
candidatos de ambos os domínios realmente combinados, decisão final do nosso Orchestrator,
|
||||
dispatch `ALLOW` de ponta a ponta.
|
||||
|
||||
## O que foi validado, por dependência real
|
||||
|
||||
| Dependência real | O que exercita | Onde |
|
||||
|---|---|---|
|
||||
| OPA (subprocess) | `entitlement.rego`/`transaction.rego`, `PDPClient` | `tests/unit/policies/`, toda a suíte e2e |
|
||||
| Keycloak (Docker, 26.7.3) | client_credentials, JWKS real, token exchange RFC 8693, Modo B | `tests/e2e/test_real_keycloak.py` |
|
||||
| LangGraph 0.6 + checkpointer SQLite | journey graph, pausa/retomada de aprovação | `tests/unit/test_orchestrator_graph.py`, e2e do M3 |
|
||||
| LLM real (OpenAI-compatible) | tool-calling do agente de exemplo, prompt injection | `tests/e2e/test_example_agent.py`, `test_multi_agent_mode_a.py` |
|
||||
| Docker Compose completo | build+up+fail-closed+trace cross-container+segmentação de rede | `tests/e2e/test_security_acceptance.py`, validação manual documentada |
|
||||
| Langfuse self-hosted (6 containers) | export OTLP real, spans de generation | Validação manual (não automatizada) |
|
||||
|
||||
## Limitações e achados arquiteturais reais
|
||||
|
||||
- **`ttl_ms` do cache hint de `tools/list` (SEP-2549, EP-05-T03)**: implementado corretamente em
|
||||
`gateway/cache.py`, mas o campo pertence ao protocolo 2026-07-28, que só existe no modo
|
||||
*stateless per-request* do SDK `mcp` (sem handshake `initialize`). Esta RI usa o handshake de
|
||||
sessão clássico em toda parte (Gateway↔Client e Gateway↔backends), então `ttl_ms` nunca chega
|
||||
ao wire nesta arquitetura — o SDK descarta o campo na serialização para versões de protocolo
|
||||
anteriores a 2026-07-28 (verificado empiricamente). `cache_scope="private"` é sempre definido,
|
||||
mas por não depender do wire-mode também não é algo que um teste e2e consiga verificar de
|
||||
forma discriminante. A propriedade de segurança real (nunca vazar catálogo entre clientes) é
|
||||
garantida de forma independente: o Gateway nunca cacheia o catálogo filtrado no servidor.
|
||||
- **Consistência header/body (EP-05-T08)**: implementada como um gate próprio
|
||||
(`gateway/canonical_request.py`) reaproveitando as constantes de campo do SDK, não como o
|
||||
ladder de validação nativo do SDK (que só se aplica ao mesmo modo *stateless per-request* de
|
||||
2026-07-28 acima) — pela mesma razão, esta RI não o alcança pelo caminho nativo.
|
||||
Cabeçalhos ausentes (um client que não implementa esse SEP) não são um erro.
|
||||
- **Adapter REST (EP-06-T04)**: implementado e testado de forma standalone
|
||||
(`fabric/adapters/rest_adapter.py`); nenhum backend de exemplo desta RI é REST (ambos são
|
||||
MCP-nativos), então o roteamento ao vivo através do Gateway para um backend `type: "rest"`
|
||||
nunca foi exercitado end-to-end.
|
||||
- **Handoff entre agentes (EP-12-T03, `execute_handoff`/`build_handoff_payload`)**: continua
|
||||
implementado e testado (`tests/unit/test_agent_runtime_handoff.py`), mas nem o Modo A
|
||||
(EP-13-T05) nem o Modo B (EP-13-T06) acabaram usando esse primitivo. O Modo A precisa de
|
||||
roteamento conversacional (qual backend responde à próxima mensagem), não de despacho de uma
|
||||
`NBADecision` específica — `GlobalSupervisor` chama `run_turn()` diretamente nos backends. O
|
||||
Modo B já combina `NBADecision`s de múltiplos clients num único passo e despacha o vencedor via
|
||||
`AgentDispatcher.dispatch` puro — não há um agente "de origem" cedendo controle a outro para
|
||||
`execute_handoff` interceptar. O primitivo permanece correto e reutilizável para um cenário
|
||||
futuro de handoff *dentro* de uma jornada Orchestrator-driven, mas essa forma específica de uso
|
||||
não existe nesta RI.
|
||||
- **Replay de requisição**: sem nonce/anti-replay nesta RI — uma requisição capturada com um
|
||||
token ainda válido pode ser reenviada com sucesso. Mitigado por tokens de vida curta
|
||||
(`credential_policy.short_lived` em todo `ClientProfile` seed) e por auditoria completa (cada
|
||||
tentativa, mesmo idêntica, gera `decision_id` próprio). Ver
|
||||
`tests/adversarial/test_protocol_identity.py`.
|
||||
- **Rate limiting / bulk exfiltration**: sem limitação de taxa de requisições no Gateway — N
|
||||
leituras legítimas consecutivas são todas permitidas. Mitigado apenas por detectabilidade a
|
||||
posteriori via auditoria (cada chamada gera evento correlacionado), nunca por prevenção em
|
||||
tempo real.
|
||||
- **Race condition real corrigida em `ApprovalService`**: `check_and_consume` fazia check-then-set
|
||||
em duas operações não atômicas — sob concorrência real de threads, duas tentativas podiam
|
||||
ambas observar "granted" antes de qualquer uma escrever "consumed" (double-spend de um grant
|
||||
single-use). Corrigido com `threading.Lock` dedicado, provado com `ThreadPoolExecutor` (20
|
||||
threads) em `tests/adversarial/test_protocol_identity.py`.
|
||||
- **Falha de audit sink podia virar erro 500 real, corrigida**: nenhuma das 8 chamadas
|
||||
`audit_sink.emit(...)` no Gateway estava protegida — uma falha no sink (disco cheio, fila
|
||||
indisponível) podia transformar uma operação já `ALLOW`ed num erro 500 para o caller, violando
|
||||
a política de não bloquear operações de baixo risco. Corrigido com o helper `_emit_audit`
|
||||
(`gateway/server.py`), que engole qualquer exceção do sink nos 8 pontos de emissão, para ALLOW
|
||||
e para DENY. Uma falha do sink em produção precisa de observabilidade própria (ver
|
||||
`Production-Recommendations.md`) — este comportamento é correto por design, mas silencioso.
|
||||
- **Roteamento do `GlobalSupervisor` (Modo A)**: por palavra-chave simples, deliberadamente não é
|
||||
a fronteira de segurança nem uma reivindicação de qualidade de roteamento — apenas um stand-in
|
||||
honesto para o que uma plataforma externa real faria.
|
||||
- **Regra de combinação do `ModeBOrchestrator` (Modo B)**: "Finance sempre vence Sales" é uma
|
||||
regra de referência simples, não uma reivindicação de otimalidade — mesma postura de
|
||||
`RuleBasedNBADecisionModel` (EP-11-T03).
|
||||
- **Stack de observabilidade (EP-08-T04)**: configuração validada (`docker compose config`), não
|
||||
exercitada com tráfego real por um teste automatizado (a subida completa com o perfil
|
||||
`observability` foi validada manualmente).
|
||||
129
RI/docs/Assumptions.en-us.md
Normal file
129
RI/docs/Assumptions.en-us.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Assumptions — assumptions and decisions of this RI
|
||||
|
||||
This document explains **what was assumed** when building the Reference Implementation (RI) and
|
||||
**why**, distinguishing that from what is an architectural guarantee of the [reference
|
||||
architecture](../README.md). It is a synthesis — not a copy — of the decision history recorded
|
||||
in [`docs/adr/`](../../docs/adr/); each ADR remains the canonical record of an individual
|
||||
decision, this document groups the "why" by theme for whoever is evaluating the RI.
|
||||
|
||||
Target audience: whoever is going to **evaluate or adapt** this RI for a real scenario and needs
|
||||
to know what was assumed versus what is a guarantee of the architecture.
|
||||
|
||||
## General scope
|
||||
|
||||
**We assume that an RI proves security properties, it is not a production-ready product.**
|
||||
Every decision below prioritizes verifiably demonstrating the architecture's invariants
|
||||
(`MaximumEntitlement` as a ceiling, fail-closed, never elevate privilege by context) over
|
||||
operational completeness (HA, scale, UI). Where the two collided, we chose the simplest RI
|
||||
that still proves the real property against a real dependency — never a mock of the very
|
||||
point being tested.
|
||||
|
||||
## Identity and PDP: closed technologies, not hypotheses to revisit
|
||||
|
||||
**We assume Keycloak as Identity Provider and OPA/Rego as PDP because they are OSS (Apache-2.0)
|
||||
and adherent to open standards** (OAuth 2.1/OIDC, Rego), not out of arbitrary preference. This
|
||||
resolves two gaps that the reference architecture left open (`README.md` §48, questions
|
||||
1 and 3) with a definitive technical decision, not an assumption — they were never revisited
|
||||
throughout the implementation. See `docs/adr/ADR-019-identity-extension.md`,
|
||||
`docs/adr/ADR-025-engine-do-pdp.md`.
|
||||
|
||||
**We rejected** a custom PDP engine in Python (more control, but reinvents policy evaluation
|
||||
with no real gain) and Cedar (also would be adequate, but OPA has greater adherence to "open
|
||||
standards" and a more mature Rego community for the scope of this RI).
|
||||
|
||||
## Operational reference Python/LangGraph/Langfuse (Agent Platform OCI)
|
||||
|
||||
**We assume as the concrete operational reference the Agent Platform OCI project by Christiano
|
||||
Hoshikawa** (`docs/research/hoshikawa-agent-platform-oci.md`) for the
|
||||
LangGraph/Langfuse/FastMCP layer, instead of inventing a generic pattern from scratch — faster,
|
||||
fewer ad hoc decisions, and a real point of comparison for what "multiagent orchestration"
|
||||
means in practice. **We reuse** from that reference: `StateGraph` with a router/supervisor per
|
||||
backend, pluggable checkpointing, the IC/NOC/GRL observability taxonomy with hybrid
|
||||
instrumentation (automatic spans + fail-open manual events), `identity.yaml`/`BusinessContext`
|
||||
to normalize identity across channels, FastMCP as the MCP server standard.
|
||||
|
||||
**We explicitly rejected** a single element of that reference: its tool authorization model via
|
||||
a static allowlist evaluated over an `agent_id` that arrives as **data from the conversation's
|
||||
input payload**, not as a claim from an independently verified authenticated identity — the
|
||||
reference project itself admits that this does not replace real authentication/authorization.
|
||||
That is exactly the anti-pattern form that the architecture forbids
|
||||
(`ADR-001`/`ADR-002`/`ADR-003`). The RI reuses the proxy *shape* of that Gateway
|
||||
(invocation/result contracts, per-tool cache, catalog) only at the Fabric/conversational UX
|
||||
layer — the ALLOW/DENY decision remains 100% in our PEP/PDP, never in a self-declared
|
||||
`agent_id`. See `docs/adr/ADR-022-referencia-hoshikawa-agent-platform-oci.md`,
|
||||
`docs/adr/ADR-023-autorizacao-nunca-vem-do-payload.md`. This finding also motivated a dedicated
|
||||
adversarial scenario (`tests/adversarial/test_payload_identity_spoofing.py`).
|
||||
|
||||
## Downstream identity
|
||||
|
||||
**We assume** token exchange (RFC 8693) when the backend supports OIDC, with a fallback to a
|
||||
service account isolated per adapter when it does not — because always reusing the client's
|
||||
token (the simpler alternative) is exactly the "blind token passthrough" that the architecture
|
||||
forbids (inbound ≠ outbound identity). See `docs/adr/ADR-020-downstream-identity.md`.
|
||||
|
||||
## Client profile granularity
|
||||
|
||||
**We assume** domain + risk tier (e.g.: `Sales.Read`=R1, `Sales.Write`=R2,
|
||||
`Finance.Payments`=R3) as the client profile granularity, instead of a single "enterprise
|
||||
admin" client or domain-only without tier — because only this granularity allows proving
|
||||
blast-radius segmentation between risk tiers within the same domain (README.md §7, §17,
|
||||
§31). See `docs/adr/ADR-008-segmentacao-por-dominio-e-risco.md`.
|
||||
|
||||
## Profile Intelligence: deterministic stub, not "real AI"
|
||||
|
||||
**We assume** that "generic and replaceable" Profile Intelligence (README.md §6.10) does not
|
||||
prescribe an algorithm, taxonomy, or real data source — because the reference architecture does
|
||||
not define any of these, deliberately. The RI implements a stub based on deterministic rules
|
||||
over synthetic attributes, behind a replaceable interface (`ProfileIntelligenceProvider`),
|
||||
which fulfills the **contract**, not the "intelligence" itself. A production fork replaces only
|
||||
the implementation, never the contract (`ProfileView`) nor the point where it connects (never
|
||||
to the PDP).
|
||||
|
||||
## Domain scope: two, not full federation
|
||||
|
||||
**We assume** two example domains (Sales, Finance) as sufficient to prove
|
||||
segmentation/blast-radius between distinct domains, without needing full multi-domain
|
||||
federation. Federation is deliberately out of scope for this RI — it is addressed in the
|
||||
reference architecture's roadmap as a later phase (§47, Phase 5), not an implementation gap.
|
||||
|
||||
## Environment: 100% local via Docker Compose
|
||||
|
||||
**We assume** that an RI runs entirely locally via `docker compose`, with environment
|
||||
separation demonstrated by a configuration overlay (`config/env/{dev,ci}.yaml`) instead of
|
||||
real cloud/hybrid infrastructure — proving the architecture does not require multi-cloud, and
|
||||
simulating multi-cloud without real multi-cloud would prove nothing beyond the simulation
|
||||
itself.
|
||||
|
||||
## Substitutions to enable testing without heavy infrastructure
|
||||
|
||||
- **Local JWKS server as a stand-in for Keycloak** in most of the e2e suite — valid because
|
||||
what is being tested there is the *token validation logic* (signature, `exp`, `iss`,
|
||||
`aud`, extraction of `azp`), which is identical against any valid RS256 JWKS. Real protocol
|
||||
integration with Keycloak (`client_credentials` grant, real claims issued by a real IdP,
|
||||
RFC 8693) is covered separately, against a real Keycloak 26.7.3 via Docker
|
||||
(`tests/e2e/test_real_keycloak.py`) — it is not the same test, it is a complementary test
|
||||
that covers exactly what the stand-in cannot.
|
||||
- **SQLite instead of Postgres** for the Registry and for the LangGraph checkpointer — valid to
|
||||
prove the contract/behavior (schema, lifecycle, graph pause/resume); it is not a claim
|
||||
about performance or concurrency under production load (see
|
||||
`Production-Recommendations.md`).
|
||||
- **`policy_version` as a file hash, not a real OPA bundle revision** — a SHA-256 of the
|
||||
content of the `.rego` files on disk is enough to prove that `policy_version` flows end to
|
||||
end through every decision and audit event; it is not a claim about policy distribution in a
|
||||
production cluster.
|
||||
|
||||
## Items deliberately out of scope
|
||||
|
||||
- **REST adapter without a live path through the Gateway** (EP-06-T04): implemented and tested
|
||||
standalone; none of this RI's example backends is REST, so there is no live path through the
|
||||
Gateway exercising it. It would only become relevant if a future domain needed a
|
||||
non-MCP-native backend.
|
||||
- **LangGraph deliberately absent from the Gateway/Fabric** — used only where there is real
|
||||
state, cycles, or checkpointing (Service Orchestrator, EP-11; Agent Runtime handoff, EP-12-T03).
|
||||
No simple routing (Gateway EP-05, Fabric EP-06, Offering Filter EP-10) uses LangGraph —
|
||||
using a stateful graph for stateless routing would be over-engineering, not a neutral choice.
|
||||
See `docs/adr/ADR-021-escopo-langgraph-langfuse.md`.
|
||||
- **Rate limiting and anti-replay**: real, conscious gaps, not oversights — documented and
|
||||
covered by a dedicated adversarial test (`tests/adversarial/test_protocol_identity.py`), never
|
||||
silently ignored. See `Production-Recommendations.md` for what a real deployment needs to
|
||||
add.
|
||||
128
RI/docs/Assumptions.md
Normal file
128
RI/docs/Assumptions.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Assumptions — premissas e decisões desta RI
|
||||
|
||||
Este documento explica **o que foi assumido** ao construir a Reference Implementation (RI) e
|
||||
**por quê**, distinguindo isso do que é garantia arquitetural da [arquitetura de
|
||||
referência](../README.md). É uma síntese — não uma cópia — do histórico de decisões registrado
|
||||
em [`docs/adr/`](../../docs/adr/); cada ADR continua sendo o registro canônico de uma decisão
|
||||
individual, este documento agrupa o "porquê" por tema para quem está avaliando a RI.
|
||||
|
||||
Público-alvo: quem vai **avaliar ou adaptar** esta RI para um cenário real e precisa saber o que
|
||||
foi assumido versus o que é garantia da arquitetura.
|
||||
|
||||
## Escopo geral
|
||||
|
||||
**Assumimos que uma RI prova propriedades de segurança, não é um produto pronto para produção.**
|
||||
Toda decisão abaixo prioriza demonstrar de forma verificável os invariantes da arquitetura
|
||||
(`MaximumEntitlement` como teto, fail-closed, nunca elevar privilégio por contexto) sobre
|
||||
completude operacional (HA, escala, UI). Onde as duas coisas colidiram, escolhemos a RI mais
|
||||
simples que ainda prova a propriedade real contra uma dependência real — nunca um mock do
|
||||
próprio ponto que estava sendo testado.
|
||||
|
||||
## Identidade e PDP: tecnologias fechadas, não hipóteses a revisitar
|
||||
|
||||
**Assumimos Keycloak como Identity Provider e OPA/Rego como PDP porque são OSS (Apache-2.0) e
|
||||
aderentes a padrões abertos** (OAuth 2.1/OIDC, Rego), não por preferência arbitrária. Isso
|
||||
resolve duas lacunas que a arquitetura de referência deixava abertas (`README.md` §48, perguntas
|
||||
1 e 3) com uma decisão técnica definitiva, não uma suposição — nunca foram revisitadas ao longo
|
||||
da implementação. Ver `docs/adr/ADR-019-identity-extension.md`,
|
||||
`docs/adr/ADR-025-engine-do-pdp.md`.
|
||||
|
||||
**Rejeitamos** um engine de PDP próprio em Python (mais controle, mas reinventa avaliação de
|
||||
política sem ganho real) e Cedar (também seria adequado, mas OPA tem maior aderência a "padrões
|
||||
abertos" e uma comunidade Rego mais madura para o escopo desta RI).
|
||||
|
||||
## Referência operacional Python/LangGraph/Langfuse (Agent Platform OCI)
|
||||
|
||||
**Assumimos como referência operacional concreta o projeto Agent Platform OCI de Christiano
|
||||
Hoshikawa** (`docs/research/hoshikawa-agent-platform-oci.md`) para a camada de
|
||||
LangGraph/Langfuse/FastMCP, em vez de inventar um padrão genérico do zero — mais rápido, menos
|
||||
decisões ad hoc, e um ponto de comparação real do que "orquestração multiagente" significa na
|
||||
prática. **Reaproveitamos** dessa referência: `StateGraph` com router/supervisor por backend,
|
||||
checkpointing plugável, taxonomia de observabilidade IC/NOC/GRL com instrumentação híbrida
|
||||
(spans automáticos + eventos manuais fail-open), `identity.yaml`/`BusinessContext` para
|
||||
normalizar identidade entre canais, FastMCP como padrão de servidor MCP.
|
||||
|
||||
**Rejeitamos explicitamente** um único elemento dessa referência: seu modelo de autorização de
|
||||
tools por allowlist estático avaliado sobre um `agent_id` que chega como **dado do payload de
|
||||
entrada da conversa**, não como claim de uma identidade autenticada verificada
|
||||
independentemente — o próprio projeto de referência admite que isso não substitui
|
||||
autenticação/autorização real. Essa é exatamente a forma de anti-padrão que a arquitetura
|
||||
proíbe (`ADR-001`/`ADR-002`/`ADR-003`). A RI reaproveita a *forma* de proxy desse Gateway
|
||||
(contratos de invocação/resultado, cache por tool, catálogo) apenas na camada de Fabric/UX
|
||||
conversacional — a decisão ALLOW/DENY continua 100% no nosso PEP/PDP, nunca em `agent_id`
|
||||
autodeclarado. Ver `docs/adr/ADR-022-referencia-hoshikawa-agent-platform-oci.md`,
|
||||
`docs/adr/ADR-023-autorizacao-nunca-vem-do-payload.md`. Este achado também motivou um cenário
|
||||
adversarial dedicado (`tests/adversarial/test_payload_identity_spoofing.py`).
|
||||
|
||||
## Downstream identity
|
||||
|
||||
**Assumimos** token exchange (RFC 8693) quando o backend suporta OIDC, com fallback para
|
||||
service account isolado por adapter quando não suporta — porque reutilizar sempre o token do
|
||||
client (a alternativa mais simples) é exatamente o "blind token passthrough" que a arquitetura
|
||||
proíbe (inbound ≠ outbound identity). Ver `docs/adr/ADR-020-downstream-identity.md`.
|
||||
|
||||
## Granularidade de client profile
|
||||
|
||||
**Assumimos** domínio + risk tier (ex.: `Sales.Read`=R1, `Sales.Write`=R2,
|
||||
`Finance.Payments`=R3) como a granularidade de client profile, em vez de um client "enterprise
|
||||
admin" único ou apenas domínio sem tier — porque só essa granularidade permite provar
|
||||
segmentação de blast-radius entre tiers de risco dentro do mesmo domínio (README.md §7, §17,
|
||||
§31). Ver `docs/adr/ADR-008-segmentacao-por-dominio-e-risco.md`.
|
||||
|
||||
## Profile Intelligence: stub determinístico, não "IA de verdade"
|
||||
|
||||
**Assumimos** que Profile Intelligence "genérica e substituível" (README.md §6.10) não prescreve
|
||||
algoritmo, taxonomia nem fonte de dados real — porque a arquitetura de referência não define
|
||||
nenhum desses, deliberadamente. A RI implementa um stub baseado em regras determinísticas sobre
|
||||
atributos sintéticos, atrás de uma interface substituível (`ProfileIntelligenceProvider`), que
|
||||
cumpre o **contrato**, não a "inteligência" em si. Um fork de produção substitui apenas a
|
||||
implementação, nunca o contrato (`ProfileView`) nem o ponto em que ele se conecta (nunca ao PDP).
|
||||
|
||||
## Escopo de domínios: dois, não federação completa
|
||||
|
||||
**Assumimos** dois domínios de exemplo (Sales, Finance) como suficientes para provar
|
||||
segmentação/blast-radius entre domínios distintos, sem precisar de federação multi-domínio
|
||||
completa. Federação fica deliberadamente fora de escopo desta RI — é tratada no roadmap da
|
||||
arquitetura de referência como uma fase posterior (§47, Fase 5), não uma lacuna de
|
||||
implementação.
|
||||
|
||||
## Ambiente: 100% local via Docker Compose
|
||||
|
||||
**Assumimos** que uma RI roda inteiramente local via `docker compose`, com separação de
|
||||
ambientes demonstrada por overlay de configuração (`config/env/{dev,ci}.yaml`) em vez de
|
||||
infraestrutura cloud/híbrida real — provar a arquitetura não exige multi-cloud, e simular
|
||||
multi-cloud sem multi-cloud real não provaria nada além da própria simulação.
|
||||
|
||||
## Substituições para viabilizar teste sem infraestrutura pesada
|
||||
|
||||
- **Servidor JWKS local como stand-in de Keycloak** na maior parte da suíte e2e — válido porque
|
||||
o que está sendo testado ali é a *lógica de validação de token* (assinatura, `exp`, `iss`,
|
||||
`aud`, extração de `azp`), que é idêntica contra qualquer JWKS RS256 válido. A integração de
|
||||
protocolo real com Keycloak (grant `client_credentials`, claims reais emitidas por um IdP real,
|
||||
RFC 8693) é coberta à parte, contra um Keycloak 26.7.3 real via Docker
|
||||
(`tests/e2e/test_real_keycloak.py`) — não é o mesmo teste, é um teste complementar que cobre
|
||||
exatamente o que o stand-in não pode.
|
||||
- **SQLite em vez de Postgres** para o Registry e para o checkpointer do LangGraph — válido para
|
||||
provar o contrato/comportamento (schema, lifecycle, pausa/retomada de grafo); não é uma
|
||||
alegação sobre desempenho ou concorrência sob carga de produção (ver
|
||||
`Production-Recommendations.md`).
|
||||
- **`policy_version` como hash de arquivo, não revisão de bundle OPA real** — um SHA-256 do
|
||||
conteúdo dos `.rego` em disco é suficiente para provar que `policy_version` flui ponta a ponta
|
||||
em toda decisão e evento de auditoria; não é uma alegação sobre distribuição de política em um
|
||||
cluster de produção.
|
||||
|
||||
## Itens deliberadamente fora de escopo
|
||||
|
||||
- **Adapter REST sem caminho ao vivo pelo Gateway** (EP-06-T04): implementado e testado
|
||||
standalone; nenhum backend de exemplo desta RI é REST, então não há um caminho ao vivo através
|
||||
do Gateway exercitando-o. Só passaria a importar se um domínio futuro precisasse de um backend
|
||||
não-MCP-nativo.
|
||||
- **LangGraph deliberadamente ausente do Gateway/Fabric** — usado apenas onde há estado,
|
||||
ciclos ou checkpointing reais (Service Orchestrator, EP-11; handoff do Agent Runtime, EP-12-T03).
|
||||
Nenhum roteamento simples (Gateway EP-05, Fabric EP-06, Offering Filter EP-10) usa LangGraph —
|
||||
usar um grafo com estado para um roteamento sem estado seria over-engineering, não uma escolha
|
||||
neutra. Ver `docs/adr/ADR-021-escopo-langgraph-langfuse.md`.
|
||||
- **Rate limiting e anti-replay**: gaps reais e conscientes, não esquecimentos — documentados e
|
||||
cobertos por teste adversarial dedicado (`tests/adversarial/test_protocol_identity.py`), nunca
|
||||
silenciosamente ignorados. Ver `Production-Recommendations.md` para o que um deployment real
|
||||
precisa adicionar.
|
||||
205
RI/docs/How-To.en-us.md
Normal file
205
RI/docs/How-To.en-us.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# How-To — Running the RI
|
||||
|
||||
Practical, task-oriented guide. To understand *what* each component does and *why*, see
|
||||
[`AS-BUILT.md`](AS-BUILT.md). Target audience: whoever is going to **run** this RI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.12+ and `pip`/`uv`.
|
||||
- Optional: `opa` binary on the PATH (or `OPA_BINARY` pointing to it) — without it, the tests that
|
||||
exercise the real PDP are skipped (`pytest.skip`), not failed.
|
||||
- Optional: `docker` on the PATH — without it, `tests/e2e/test_real_keycloak.py` and
|
||||
`tests/e2e/test_security_acceptance.py` are skipped for the same reason.
|
||||
- Optional: credentials for an OpenAI-compatible LLM (`LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL`,
|
||||
see `.env.example`) — without them, `tests/e2e/test_example_agent.py` and
|
||||
`tests/e2e/test_multi_agent_mode_a.py` are skipped.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
make install # creates .venv and installs the package in editable mode (dev extras included)
|
||||
```
|
||||
|
||||
### Installing OPA (without Docker)
|
||||
|
||||
OPA is a static Go binary — it doesn't need Docker or a build step:
|
||||
|
||||
```bash
|
||||
curl -sL -o /tmp/opa "https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static"
|
||||
chmod +x /tmp/opa
|
||||
cp /tmp/opa .venv/bin/opa
|
||||
opa version
|
||||
```
|
||||
|
||||
Reference version used in this RI: OPA v1.20.2 (Rego v1 is the default — `config/policies/*.rego`
|
||||
uses `import rego.v1`).
|
||||
|
||||
## Lint, Typecheck, and Schema Validation
|
||||
|
||||
```bash
|
||||
make lint # ruff check + ruff format --check
|
||||
make typecheck # mypy --strict
|
||||
make validate-schemas # validates all YAML under config/ against its Pydantic model
|
||||
make export-schemas # regenerates schemas/*.schema.json from the models
|
||||
```
|
||||
|
||||
## Running the Tests
|
||||
|
||||
```bash
|
||||
make test # == pytest --forked
|
||||
```
|
||||
|
||||
`--forked` (pytest-forked) isolates each test function in its own process — necessary because
|
||||
several in-process ASGI server pairs, sharing a single event loop across many sequential tests
|
||||
within the same pytest session, produced intermittent async cleanup failures. With `--forked`,
|
||||
the suite is 100% deterministic across repeated runs. The cost is only execution time (about
|
||||
+1min on this reference machine). `make test`/CI use `--forked` by default.
|
||||
|
||||
Per suite, what each one proves:
|
||||
|
||||
- `pytest tests/unit --forked` — isolated behavior of each component (schemas, PDP,
|
||||
entitlement, authn, registry, downstream identity, approval, fabric router, audit, canonical
|
||||
request, rest adapter, bypass detection, gateway cache, shared client lint, profile
|
||||
intelligence, offering filter + property invariants, orchestrator, agent runtime, PII
|
||||
redaction, prompt store).
|
||||
- `pytest tests/e2e --forked` — end-to-end flows against a real in-process Gateway
|
||||
(`test_walking_skeleton.py`, `test_governed_gateway.py`), a real Keycloak/Docker
|
||||
(`test_real_keycloak.py`), a real LLM (`test_example_agent.py`, `test_multi_agent_mode_a.py`),
|
||||
fail policy (`test_fail_closed.py`).
|
||||
- `pytest tests/contract --forked` — the input/output contract of each tool, connecting directly
|
||||
to each domain server (never through the Gateway) — a tool's contract belongs to the domain
|
||||
server, regardless of who is or isn't entitled to call it.
|
||||
- `pytest tests/adversarial --forked` — adversarial prompt/tool injection scenarios
|
||||
(`test_prompt_injection.py`, `test_tool_poisoning.py`, `test_payload_identity_spoofing.py`) and
|
||||
protocol/identity scenarios (`test_protocol_identity.py`).
|
||||
- `pytest tests/e2e/test_security_acceptance.py -v` — the formal security acceptance suite,
|
||||
against the real `docker-compose.yml` (`--profile core --profile identity`, Keycloak included).
|
||||
It **does not** run with `--forked`/`make test` — it requires Docker and pays the
|
||||
build+Keycloak cost per run, which is why it's run separately:
|
||||
```bash
|
||||
pytest tests/e2e/test_security_acceptance.py -v
|
||||
```
|
||||
|
||||
## Bringing Up the Local Stack
|
||||
|
||||
### Without Docker
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
opa run --server --addr 127.0.0.1:8181 config/policies/ &
|
||||
export SALES_DOMAIN_SERVICE_TOKEN=dev-sales-domain-service-token
|
||||
export FINANCE_DOMAIN_SERVICE_TOKEN=dev-finance-domain-service-token
|
||||
python3 -m example_mcp_servers.sales_domain.server &
|
||||
python3 -m example_mcp_servers.finance_domain.server &
|
||||
python3 -m emcp_bus.gateway.server
|
||||
```
|
||||
|
||||
Without `OIDC_JWKS_URL`/`OIDC_ISSUER` set, the Gateway runs in "no IdP configured" mode: it
|
||||
denies everything by default (`tools/list` empty, `tools/call` always `UNAUTHENTICATED`) — see
|
||||
[`interfaces/mcp-gateway.md`](interfaces/mcp-gateway.md#dois-modos-de-deployment) for the two
|
||||
modes.
|
||||
|
||||
### With Docker Compose
|
||||
|
||||
```bash
|
||||
make up # == docker compose --profile core up --build (without IdP)
|
||||
make down
|
||||
make logs
|
||||
```
|
||||
|
||||
Available profiles (`docker-compose.yml`):
|
||||
|
||||
| Profile | Services | What it adds |
|
||||
|---|---|---|
|
||||
| `core` | `opa`, `sales-domain`, `finance-domain`, `gateway` | PEP/PDP + the two example domains — minimal functional setup |
|
||||
| `identity` | + `keycloak` | real OIDC authentication |
|
||||
| `observability` | + `otel-collector`, `jaeger` | OTLP tracing (Jaeger UI at `http://localhost:16686`) |
|
||||
|
||||
With a real Keycloak:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # uncomment the 3 OIDC_* lines
|
||||
docker compose --profile core --profile identity up --build
|
||||
```
|
||||
|
||||
All profiles simultaneously:
|
||||
|
||||
```bash
|
||||
docker compose --profile core --profile identity --profile observability up -d --build
|
||||
```
|
||||
|
||||
## Running the Example Agent and the Multi-Agent Modes
|
||||
|
||||
Requires `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` in `.env` (any OpenAI-compatible endpoint;
|
||||
validated against `meta.llama-3.3-70b-instruct` via OCI Generative AI).
|
||||
|
||||
```bash
|
||||
pytest tests/e2e/test_example_agent.py -v # single agent, real tool-calling
|
||||
pytest tests/e2e/test_multi_agent_mode_a.py -v # Mode A (external Global Supervisor, ADR-024)
|
||||
pytest tests/e2e/test_real_keycloak.py -k mode_b -v # Mode B (Orchestrator as sole brain)
|
||||
```
|
||||
|
||||
Mode B requires Docker (it brings up its own real Keycloak).
|
||||
|
||||
## Publishing a New Capability
|
||||
|
||||
1. Write the YAML manifest at `config/capabilities/<domain>/<capability>.yaml`, following the
|
||||
`CapabilityManifest` schema (see [`interfaces/registry-capability-manifest.md`](interfaces/registry-capability-manifest.md)
|
||||
for the full schema, and any file under `config/capabilities/*/` as a real example —
|
||||
canonical name `<domain>.<capability>`, `risk_tier`, `owner`, `entitlements`, `backend.service`
|
||||
pointing to a backend already registered under `config/backends/`).
|
||||
2. If the capability needs a new backend, register it at `config/backends/<backend>.yaml`
|
||||
(see [`interfaces/downstream-identity.md`](interfaces/downstream-identity.md)).
|
||||
3. Associate the capability with an existing or new `ClientProfile` at
|
||||
`config/clients/profiles/<profile>.yaml`, adding the canonical name to `allowed_tools`.
|
||||
4. Run `make validate-schemas` — it validates the new YAML against its Pydantic model before
|
||||
anything else.
|
||||
5. Run `make export-schemas` if the Pydantic model itself changed (not necessary just to add an
|
||||
example YAML to an already-existing schema).
|
||||
6. Restart the Gateway (or the Docker stack) — the Registry is seeded from
|
||||
`config/capabilities/` at boot (`registry/seed.py`); there is no hot-reload in this RI.
|
||||
|
||||
## Registering a New MCP Client
|
||||
|
||||
1. Register the client in the Keycloak realm (or OIDC equivalent) with the `client_credentials`
|
||||
grant enabled and an audience mapper for the Gateway's `OIDC_AUDIENCE` (`emcp-gateway` by
|
||||
default) — see `deploy/keycloak/realm-export.json` for the 3 example clients already
|
||||
configured this way.
|
||||
2. Create `config/clients/<client-id>.yaml` (`ClientRegistration`: `client_id`, `profile`,
|
||||
`environment`) pointing to an existing or new `ClientProfile` under
|
||||
`config/clients/profiles/`.
|
||||
3. Run `make validate-schemas`.
|
||||
4. Restart the Gateway — client registrations are also loaded at boot
|
||||
(`load_yaml_models(config.clients_dir, ClientRegistration)`).
|
||||
|
||||
## Observability
|
||||
|
||||
### Jaeger (Tracing)
|
||||
|
||||
```bash
|
||||
docker compose --profile core --profile observability up -d --build
|
||||
# UI: http://localhost:16686
|
||||
```
|
||||
|
||||
### Self-Hosted Langfuse (LLM Observability)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f deploy/langfuse/docker-compose.override.yml \
|
||||
--profile llm-observability up -d
|
||||
# Wait for it:
|
||||
curl http://localhost:3000/api/public/health
|
||||
# Create a project/API key in the UI (http://localhost:3000) or pre-seed via
|
||||
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY/LANGFUSE_INIT_PROJECT_SECRET_KEY
|
||||
# (see deploy/langfuse/docker-compose.override.yml).
|
||||
```
|
||||
|
||||
Then, point the standard OTel SDK variables to Langfuse's native OTLP endpoint (no code changes
|
||||
needed):
|
||||
|
||||
```bash
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=http://langfuse-web:3000/api/public/otel
|
||||
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <base64(public_key:secret_key)>"
|
||||
```
|
||||
|
||||
This profile is heavy (6 containers: web/worker/postgres/clickhouse/redis/minio) and opt-in,
|
||||
decoupled from the `core`/`identity`/`observability` profiles.
|
||||
206
RI/docs/How-To.md
Normal file
206
RI/docs/How-To.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# How-To — rodando a RI
|
||||
|
||||
Guia prático orientado a tarefas. Para entender *o que* cada componente faz e *por que*, ver
|
||||
[`AS-BUILT.md`](AS-BUILT.md). Público-alvo: quem vai **rodar** esta RI.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Python 3.12+ e `pip`/`uv`.
|
||||
- Opcional: binário `opa` no PATH (ou `OPA_BINARY` apontando para ele) — sem ele, os testes que
|
||||
exercitam o PDP real são pulados (`pytest.skip`), não falham.
|
||||
- Opcional: `docker` no PATH — sem ele, `tests/e2e/test_real_keycloak.py` e
|
||||
`tests/e2e/test_security_acceptance.py` são pulados pela mesma razão.
|
||||
- Opcional: credenciais de um LLM OpenAI-compatible (`LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL`,
|
||||
ver `.env.example`) — sem elas, `tests/e2e/test_example_agent.py` e
|
||||
`tests/e2e/test_multi_agent_mode_a.py` são pulados.
|
||||
|
||||
## Instalação
|
||||
|
||||
```bash
|
||||
make install # cria .venv e instala o pacote em modo editável (dev extras inclusos)
|
||||
```
|
||||
|
||||
### Instalar o OPA (sem Docker)
|
||||
|
||||
OPA é um binário Go estático — não precisa de Docker nem de build:
|
||||
|
||||
```bash
|
||||
curl -sL -o /tmp/opa "https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static"
|
||||
chmod +x /tmp/opa
|
||||
cp /tmp/opa .venv/bin/opa
|
||||
opa version
|
||||
```
|
||||
|
||||
Versão de referência usada nesta RI: OPA v1.20.2 (Rego v1 é o padrão — `config/policies/*.rego`
|
||||
usa `import rego.v1`).
|
||||
|
||||
## Lint, typecheck e validação de schema
|
||||
|
||||
```bash
|
||||
make lint # ruff check + ruff format --check
|
||||
make typecheck # mypy --strict
|
||||
make validate-schemas # valida todo YAML de config/ contra seu modelo Pydantic
|
||||
make export-schemas # regenera schemas/*.schema.json a partir dos modelos
|
||||
```
|
||||
|
||||
## Rodando os testes
|
||||
|
||||
```bash
|
||||
make test # == pytest --forked
|
||||
```
|
||||
|
||||
`--forked` (pytest-forked) isola cada função de teste em seu próprio processo — necessário
|
||||
porque vários pares de servidor ASGI em processo, compartilhando um único event loop entre
|
||||
muitos testes sequenciais na mesma sessão pytest, produziam falhas intermitentes de limpeza
|
||||
assíncrona. Com `--forked`, a suíte é 100% determinística em execuções repetidas. O custo é só
|
||||
tempo de execução (cerca de +1min nesta máquina de referência). `make test`/CI usam `--forked`
|
||||
por padrão.
|
||||
|
||||
Por suíte, o que cada uma prova:
|
||||
|
||||
- `pytest tests/unit --forked` — comportamento isolado de cada componente (schemas, PDP,
|
||||
entitlement, authn, registry, downstream identity, approval, fabric router, audit, canonical
|
||||
request, rest adapter, bypass detection, gateway cache, shared client lint, profile
|
||||
intelligence, offering filter + invariantes de propriedade, orchestrator, agent runtime, PII
|
||||
redaction, prompt store).
|
||||
- `pytest tests/e2e --forked` — fluxos ponta a ponta contra um Gateway real em processo
|
||||
(`test_walking_skeleton.py`, `test_governed_gateway.py`), Keycloak/Docker real
|
||||
(`test_real_keycloak.py`), LLM real (`test_example_agent.py`, `test_multi_agent_mode_a.py`),
|
||||
política de falha (`test_fail_closed.py`).
|
||||
- `pytest tests/contract --forked` — o contrato input/output de cada tool, conectando direto a
|
||||
cada domain server (nunca pelo Gateway) — o contrato de uma tool é propriedade do domain
|
||||
server, independente de quem está ou não entitled a chamá-la.
|
||||
- `pytest tests/adversarial --forked` — cenários adversariais de prompt/tool injection
|
||||
(`test_prompt_injection.py`, `test_tool_poisoning.py`, `test_payload_identity_spoofing.py`) e
|
||||
de protocolo/identidade (`test_protocol_identity.py`).
|
||||
- `pytest tests/e2e/test_security_acceptance.py -v` — a suíte de aceite de segurança formal,
|
||||
contra o `docker-compose.yml` real (`--profile core --profile identity`, Keycloak incluído).
|
||||
**Não** roda com `--forked`/`make test` — exige Docker e paga o custo de build+Keycloak por
|
||||
execução, por isso é rodada separadamente:
|
||||
```bash
|
||||
pytest tests/e2e/test_security_acceptance.py -v
|
||||
```
|
||||
|
||||
## Subindo a stack local
|
||||
|
||||
### Sem Docker
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
opa run --server --addr 127.0.0.1:8181 config/policies/ &
|
||||
export SALES_DOMAIN_SERVICE_TOKEN=dev-sales-domain-service-token
|
||||
export FINANCE_DOMAIN_SERVICE_TOKEN=dev-finance-domain-service-token
|
||||
python3 -m example_mcp_servers.sales_domain.server &
|
||||
python3 -m example_mcp_servers.finance_domain.server &
|
||||
python3 -m emcp_bus.gateway.server
|
||||
```
|
||||
|
||||
Sem `OIDC_JWKS_URL`/`OIDC_ISSUER` definidos, o Gateway roda em modo "sem IdP configurado": nega
|
||||
tudo por padrão (`tools/list` vazio, `tools/call` sempre `UNAUTHENTICATED`) — ver
|
||||
[`interfaces/mcp-gateway.md`](interfaces/mcp-gateway.md#dois-modos-de-deployment) para os dois
|
||||
modos.
|
||||
|
||||
### Com Docker Compose
|
||||
|
||||
```bash
|
||||
make up # == docker compose --profile core up --build (sem IdP)
|
||||
make down
|
||||
make logs
|
||||
```
|
||||
|
||||
Perfis disponíveis (`docker-compose.yml`):
|
||||
|
||||
| Perfil | Serviços | O que adiciona |
|
||||
|---|---|---|
|
||||
| `core` | `opa`, `sales-domain`, `finance-domain`, `gateway` | PEP/PDP + os dois domínios de exemplo — mínimo funcional |
|
||||
| `identity` | + `keycloak` | autenticação OIDC real |
|
||||
| `observability` | + `otel-collector`, `jaeger` | tracing OTLP (UI Jaeger em `http://localhost:16686`) |
|
||||
|
||||
Com Keycloak real:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # descomente as 3 linhas OIDC_*
|
||||
docker compose --profile core --profile identity up --build
|
||||
```
|
||||
|
||||
Todos os perfis simultaneamente:
|
||||
|
||||
```bash
|
||||
docker compose --profile core --profile identity --profile observability up -d --build
|
||||
```
|
||||
|
||||
## Rodando o agente de exemplo e os modos multiagente
|
||||
|
||||
Requer `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` em `.env` (qualquer endpoint OpenAI-compatible;
|
||||
validado contra `meta.llama-3.3-70b-instruct` via OCI Generative AI).
|
||||
|
||||
```bash
|
||||
pytest tests/e2e/test_example_agent.py -v # agente único, tool-calling real
|
||||
pytest tests/e2e/test_multi_agent_mode_a.py -v # Modo A (Global Supervisor externo, ADR-024)
|
||||
pytest tests/e2e/test_real_keycloak.py -k mode_b -v # Modo B (Orchestrator como único cérebro)
|
||||
```
|
||||
|
||||
Modo B exige Docker (sobe seu próprio Keycloak real).
|
||||
|
||||
## Publicando uma nova capability
|
||||
|
||||
1. Escreva o manifesto YAML em `config/capabilities/<domain>/<capability>.yaml`, seguindo o
|
||||
schema `CapabilityManifest` (ver [`interfaces/registry-capability-manifest.md`](interfaces/registry-capability-manifest.md)
|
||||
para o schema completo, e qualquer arquivo em `config/capabilities/*/` como exemplo real —
|
||||
nome canônico `<domain>.<capability>`, `risk_tier`, `owner`, `entitlements`, `backend.service`
|
||||
apontando para um backend já registrado em `config/backends/`).
|
||||
2. Se a capability precisar de um backend novo, registre-o em `config/backends/<backend>.yaml`
|
||||
(ver [`interfaces/downstream-identity.md`](interfaces/downstream-identity.md)).
|
||||
3. Associe a capability a um `ClientProfile` existente ou novo em
|
||||
`config/clients/profiles/<profile>.yaml`, adicionando o nome canônico a `allowed_tools`.
|
||||
4. Rode `make validate-schemas` — valida o novo YAML contra seu modelo Pydantic antes de
|
||||
qualquer coisa.
|
||||
5. Rode `make export-schemas` se o modelo Pydantic em si mudou (não necessário para apenas
|
||||
adicionar um YAML de exemplo a um schema já existente).
|
||||
6. Reinicie o Gateway (ou a stack Docker) — o Registry é semeado a partir de
|
||||
`config/capabilities/` no boot (`registry/seed.py`); não há hot-reload nesta RI.
|
||||
|
||||
## Registrando um novo MCP Client
|
||||
|
||||
1. Registre o client no realm Keycloak (ou equivalente OIDC) com grant `client_credentials`
|
||||
habilitado e um mapper de audience para o `OIDC_AUDIENCE` do Gateway (`emcp-gateway` por
|
||||
padrão) — ver `deploy/keycloak/realm-export.json` para os 3 clients de exemplo já
|
||||
configurados dessa forma.
|
||||
2. Crie `config/clients/<client-id>.yaml` (`ClientRegistration`: `client_id`, `profile`,
|
||||
`environment`) apontando para um `ClientProfile` existente ou novo em
|
||||
`config/clients/profiles/`.
|
||||
3. Rode `make validate-schemas`.
|
||||
4. Reinicie o Gateway — registros de client também são carregados no boot
|
||||
(`load_yaml_models(config.clients_dir, ClientRegistration)`).
|
||||
|
||||
## Observabilidade
|
||||
|
||||
### Jaeger (tracing)
|
||||
|
||||
```bash
|
||||
docker compose --profile core --profile observability up -d --build
|
||||
# UI: http://localhost:16686
|
||||
```
|
||||
|
||||
### Langfuse self-hosted (observabilidade de LLM)
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f deploy/langfuse/docker-compose.override.yml \
|
||||
--profile llm-observability up -d
|
||||
# Aguardar:
|
||||
curl http://localhost:3000/api/public/health
|
||||
# Criar um projeto/API key na UI (http://localhost:3000) ou pré-semear via
|
||||
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY/LANGFUSE_INIT_PROJECT_SECRET_KEY
|
||||
# (ver deploy/langfuse/docker-compose.override.yml).
|
||||
```
|
||||
|
||||
Depois, aponte as variáveis padrão do OTel SDK para o endpoint OTLP nativo do Langfuse (nenhuma
|
||||
mudança de código):
|
||||
|
||||
```bash
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=http://langfuse-web:3000/api/public/otel
|
||||
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <base64(public_key:secret_key)>"
|
||||
```
|
||||
|
||||
Este perfil é pesado (6 containers: web/worker/postgres/clickhouse/redis/minio) e opt-in,
|
||||
desacoplado dos perfis `core`/`identity`/`observability`.
|
||||
105
RI/docs/Production-Recommendations.en-us.md
Normal file
105
RI/docs/Production-Recommendations.en-us.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Production Recommendations — what changes for a real production environment
|
||||
|
||||
Synthesis of what a real production deployment would need to change relative to this Reference
|
||||
Implementation (RI), derived from the real limitations found and documented during the
|
||||
implementation (see [`AS-BUILT.md`](AS-BUILT.md#limitações-e-achados-arquiteturais-reais)
|
||||
for the detail of each finding). Nothing here is hypothetical: each item corresponds to a real
|
||||
gap verified in testing, not to an assumption about what "might" be missing.
|
||||
|
||||
Target audience: whoever is going to **evaluate or adapt** this RI for a real scenario.
|
||||
|
||||
## Identity Provider
|
||||
|
||||
A real, highly available IdP (Keycloak cluster, or a managed equivalent), not the single
|
||||
instance from `docker-compose.yml`. The extension to mTLS/workload identity — covering cases
|
||||
where `client_credentials` is not strong enough (e.g.: workloads in a service mesh) — is
|
||||
already documented as an extension point in `docs/adr/ADR-019-identity-extension.md`; production
|
||||
would implement that extension, not reinvent the authentication middleware from scratch.
|
||||
|
||||
## PDP
|
||||
|
||||
Policy distribution via a real OPA bundle (a bundle server, not point-to-point
|
||||
`push_client_profile` at Gateway boot) — enables versioning, rollback, and consistent
|
||||
distribution to multiple Gateway replicas. `policy_version` would stop being a hash of local
|
||||
files (`pdp/client.py:compute_policy_version`, sufficient to prove that the field flows end to
|
||||
end) and would become the real revision of the published bundle.
|
||||
|
||||
## Registry
|
||||
|
||||
A durable store (Postgres) instead of the current SQLite (`registry/store.py`) — SQLite
|
||||
correctly proves the contract/lifecycle, but is not the right choice for write concurrency
|
||||
across multiple Gateway replicas in production. A real administrative endpoint for
|
||||
capability revocation/lifecycle also does not exist today: the state transitions
|
||||
(`registry/lifecycle.py`) are exercised via direct calls to internal methods in tests, not
|
||||
via a protected HTTP API that an external governance system could call.
|
||||
|
||||
## Audit
|
||||
|
||||
A durable sink (Postgres, or a queue like Kafka/SQS) instead of the current append-only JSONL
|
||||
(`audit/sink.py`). More importantly: a real alert connected to sink failure. The Gateway's
|
||||
current behavior — swallowing any exception from `audit_sink.emit()` (`gateway/server.py:
|
||||
_emit_audit`) so that an audit failure on a low-risk operation never turns into a 500 error —
|
||||
is **correct by design**, not a bug to remove; but in production, a silent sink failure that is
|
||||
never observed is, in practice, an audit blackout with no alarm. Production needs dedicated
|
||||
observability over the sink itself (a failure metric for `emit`, an alert), not a change to the
|
||||
Gateway's fail-open behavior.
|
||||
|
||||
## Approval workflow
|
||||
|
||||
A persistent store (not the in-memory `dict` of `approval/service.py`, which loses all pending
|
||||
approval state on a process restart) and a real notification/UI integration for the
|
||||
approver — today `grant()`/`deny()` are called via CLI/test, with no real interface for a
|
||||
human.
|
||||
|
||||
## Rate limiting and anti-replay
|
||||
|
||||
Neither of the two exists in this RI today — real gaps, documented and covered by a dedicated
|
||||
adversarial test (`tests/adversarial/test_protocol_identity.py`), never hidden. A production
|
||||
Gateway needs both: anti-replay (a nonce, or a deduplication window per
|
||||
`mcp_request_id`) to close the byte-identical request replay vector with a still-valid token;
|
||||
rate limiting to contain mass exfiltration via repeated legitimate read calls — today mitigated
|
||||
only by after-the-fact detectability via audit, never by real-time prevention.
|
||||
|
||||
## Observability
|
||||
|
||||
The `otel-collector` + Jaeger of the `observability` profile has validated configuration
|
||||
(`docker compose config`), but has not received real traffic from an automated test —
|
||||
production needs a real OTLP collector/backend actually receiving and retaining traffic, with
|
||||
dashboards and alerts on the correlation `span attributes` already emitted (`decision_id`,
|
||||
`policy_decision_id`, `entitlement_version`). The self-hosted Langfuse (EP-13-T02) is this RI's
|
||||
only OTLP consumer validated with real traffic, and only manually — replicating that validation
|
||||
as part of CI is a real investment, not just turning on the profile.
|
||||
|
||||
## Secrets
|
||||
|
||||
A real secrets manager (Vault, a managed KMS, or equivalent) instead of environment variables
|
||||
with development values (`SALES_DOMAIN_SERVICE_TOKEN`,
|
||||
`GATEWAY_TOKEN_EXCHANGE_CLIENT_SECRET`, etc., see `.env.example`) — the indirection *mechanism*
|
||||
(`BackendConfig.service_account_token_env_var` holds the *name* of the variable, never a
|
||||
literal value, validated by `field_validator`) is already correct and stays as-is: production
|
||||
changes only where the env var is populated from, not how the Gateway consumes it.
|
||||
|
||||
## Scale and high availability
|
||||
|
||||
The Gateway does not maintain shared state between requests — each `tools/call` opens its own
|
||||
upstream session (`gateway/server.py:_with_downstream_session`), with no persistent connection
|
||||
pool or in-memory session state between calls. This is favorable for scaling horizontally
|
||||
(multiple replicas behind a load balancer, without sticky sessions). PDP, Registry, and
|
||||
Approval, on the other hand, each need their own HA strategy in production (see sections
|
||||
above) — none of the three was designed for multiple replicas in the RI.
|
||||
|
||||
## Multiagent (ADR-024)
|
||||
|
||||
- **Mode A**: the `GlobalSupervisor` router (`agent_runtime/global_supervisor.py`) is a
|
||||
deliberately simple keyword-based stand-in — it is not, and never intended to be, a claim of
|
||||
routing quality. Production would replace `RouterFn` with real intent classification
|
||||
(LLM or rules engine), with no change to the security boundary (each backend continues to
|
||||
resolve its own entitlement independently, an invariant that does not depend on router
|
||||
quality).
|
||||
- **Mode B**: the `ModeBOrchestrator` invokes the journey graph once per client and combines the
|
||||
results in a layer above the graph — it does not scale natively to many domains without an
|
||||
extension of `JourneyState`/`build_journey_graph` for multiple clients in a single run (a
|
||||
design decision documented in the module itself, deliberately not attempted in this RI so as
|
||||
not to risk an already-tested component in a `could` demo). The current combination rule
|
||||
("Finance always beats Sales") is also a reference rule, not a real ranking model — production
|
||||
would replace `ModeBOrchestrator._combine` with a real cross-domain `NBADecisionModel`.
|
||||
106
RI/docs/Production-Recommendations.md
Normal file
106
RI/docs/Production-Recommendations.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Production Recommendations — o que muda para um ambiente produtivo real
|
||||
|
||||
Síntese do que um deployment de produção real precisaria mudar em relação a esta Reference
|
||||
Implementation (RI), derivada das limitações reais encontradas e documentadas durante a
|
||||
implementação (ver [`AS-BUILT.md`](AS-BUILT.md#limitações-e-achados-arquiteturais-reais)
|
||||
para o detalhe de cada achado). Nada aqui é hipotético: cada item corresponde a uma lacuna real
|
||||
verificada em teste, não a uma suposição sobre o que "poderia" faltar.
|
||||
|
||||
Público-alvo: quem vai **avaliar ou adaptar** esta RI para um cenário real.
|
||||
|
||||
## Identity Provider
|
||||
|
||||
Um IdP real em alta disponibilidade (cluster Keycloak, ou equivalente gerenciado), não a
|
||||
instância única do `docker-compose.yml`. A extensão para mTLS/workload identity — cobrindo casos
|
||||
em que `client_credentials` não é forte o suficiente (ex.: workloads em malha de serviço) — já
|
||||
está documentada como ponto de extensão em `docs/adr/ADR-019-identity-extension.md`; produção
|
||||
implementaria essa extensão, não reinventaria o middleware de autenticação do zero.
|
||||
|
||||
## PDP
|
||||
|
||||
Distribuição de política via bundle OPA real (um bundle server, não `push_client_profile`
|
||||
ponto-a-ponto no boot do Gateway) — permite versionamento, rollback e distribuição consistente
|
||||
para múltiplas réplicas do Gateway. `policy_version` deixaria de ser um hash de arquivos locais
|
||||
(`pdp/client.py:compute_policy_version`, suficiente para provar que o campo flui ponta a ponta)
|
||||
e passaria a ser a revisão real do bundle publicado.
|
||||
|
||||
## Registry
|
||||
|
||||
Um store durável (Postgres) em vez do SQLite atual (`registry/store.py`) — SQLite prova o
|
||||
contrato/lifecycle corretamente, mas não é a escolha certa para concorrência de escrita entre
|
||||
múltiplas réplicas do Gateway em produção. Um endpoint administrativo real para
|
||||
revogação/lifecycle de capability também não existe hoje: as transições de estado
|
||||
(`registry/lifecycle.py`) são exercitadas via chamada direta aos métodos internos em teste, não
|
||||
via uma API HTTP protegida que um sistema de governança externo possa chamar.
|
||||
|
||||
## Audit
|
||||
|
||||
Um sink durável (Postgres, ou uma fila como Kafka/SQS) em vez do JSONL append-only atual
|
||||
(`audit/sink.py`). Mais importante: um alerta real conectado à falha do sink. O comportamento
|
||||
atual do Gateway — engolir qualquer exceção de `audit_sink.emit()` (`gateway/server.py:
|
||||
_emit_audit`) para nunca transformar uma falha de auditoria numa operação de baixo risco em erro
|
||||
500 — é **correto por design**, não um bug a remover; mas em produção, uma falha silenciosa do
|
||||
sink que nunca é observada é, na prática, um blackout de auditoria sem alarme. Produção precisa
|
||||
de observabilidade dedicada sobre o próprio sink (métrica de falha de `emit`, alerta), não de
|
||||
mudar o comportamento fail-open do Gateway.
|
||||
|
||||
## Approval workflow
|
||||
|
||||
Um store persistente (não o `dict` em memória de `approval/service.py`, que perde todo o estado
|
||||
de aprovações pendentes num restart do processo) e uma integração real de
|
||||
notificação/UI para o aprovador — hoje `grant()`/`deny()` são chamados via CLI/teste, sem
|
||||
interface real para um humano.
|
||||
|
||||
## Rate limiting e anti-replay
|
||||
|
||||
Nenhum dos dois existe nesta RI hoje — gaps reais, documentados e cobertos por teste adversarial
|
||||
dedicado (`tests/adversarial/test_protocol_identity.py`), nunca escondidos. Um Gateway de
|
||||
produção precisa de ambos: anti-replay (nonce, ou uma janela de deduplicação por
|
||||
`mcp_request_id`) para fechar o vetor de replay de requisição byte-idêntica com um token ainda
|
||||
válido; rate limiting para conter exfiltração em massa via chamadas de leitura legítimas
|
||||
repetidas — hoje mitigado apenas por detectabilidade a posteriori via auditoria, nunca por
|
||||
prevenção em tempo real.
|
||||
|
||||
## Observabilidade
|
||||
|
||||
O `otel-collector` + Jaeger do perfil `observability` tem a configuração validada
|
||||
(`docker compose config`), mas não recebeu tráfego real de um teste automatizado — produção
|
||||
precisa de um coletor/backend OTLP real de fato recebendo e retendo tráfego, com dashboards e
|
||||
alertas sobre os `span attributes` de correlação já emitidos (`decision_id`,
|
||||
`policy_decision_id`, `entitlement_version`). O Langfuse self-hosted (EP-13-T02) é o único
|
||||
consumidor OTLP desta RI validado com tráfego real, e apenas manualmente — replicar essa
|
||||
validação como parte de CI é um investimento real, não apenas ligar o profile.
|
||||
|
||||
## Segredos
|
||||
|
||||
Um gerenciador de segredos real (Vault, um KMS gerenciado, ou equivalente) em vez de variáveis
|
||||
de ambiente com valores de desenvolvimento (`SALES_DOMAIN_SERVICE_TOKEN`,
|
||||
`GATEWAY_TOKEN_EXCHANGE_CLIENT_SECRET`, etc., ver `.env.example`) — o *mecanismo* de indireção
|
||||
(`BackendConfig.service_account_token_env_var` guarda o *nome* da variável, nunca um valor
|
||||
literal, validado por `field_validator`) já está correto e se mantém: produção troca apenas de
|
||||
onde a env var é populada, não a forma como o Gateway a consome.
|
||||
|
||||
## Escala e alta disponibilidade
|
||||
|
||||
O Gateway não mantém estado compartilhado entre requisições — cada `tools/call` abre uma sessão
|
||||
própria a montante (`gateway/server.py:_with_downstream_session`), sem pool de conexão
|
||||
persistente nem estado de sessão em memória entre chamadas. Isso é favorável a escalar
|
||||
horizontalmente (múltiplas réplicas atrás de um load balancer, sem sticky sessions). PDP,
|
||||
Registry e Approval, por outro lado, cada um precisa de sua própria estratégia de HA em
|
||||
produção (ver seções acima) — nenhum dos três foi desenhado para múltiplas réplicas na RI.
|
||||
|
||||
## Multiagente (ADR-024)
|
||||
|
||||
- **Modo A**: o roteador do `GlobalSupervisor` (`agent_runtime/global_supervisor.py`) é um
|
||||
stand-in deliberadamente simples baseado em palavra-chave — não é, e nunca pretendeu ser, uma
|
||||
reivindicação de qualidade de roteamento. Produção substituiria `RouterFn` por uma
|
||||
classificação de intenção real (LLM ou motor de regras), sem qualquer mudança na fronteira de
|
||||
segurança (cada backend continua resolvendo seu próprio entitlement de forma independente,
|
||||
invariante que não depende da qualidade do roteador).
|
||||
- **Modo B**: o `ModeBOrchestrator` invoca o journey graph uma vez por client e combina os
|
||||
resultados numa camada acima do grafo — não escala nativamente para muitos domínios sem uma
|
||||
extensão de `JourneyState`/`build_journey_graph` para múltiplos clients num único run (decisão
|
||||
de design documentada no próprio módulo, deliberadamente não tentada nesta RI para não
|
||||
arriscar um componente já testado numa demo `could`). A regra de combinação atual ("Finance
|
||||
sempre vence Sales") também é uma regra de referência, não um modelo de ranking real — produção
|
||||
substituiria `ModeBOrchestrator._combine` por um `NBADecisionModel` cross-domain de verdade.
|
||||
126
RI/docs/interfaces/agent-runtime-dispatch.en-us.md
Normal file
126
RI/docs/interfaces/agent-runtime-dispatch.en-us.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Interface: Agent Runtime dispatch (`AgentDispatcher`, `GlobalSupervisor`, `ModeBOrchestrator`)
|
||||
|
||||
Agent Runtime dispatch contracts, for whoever is going to build a new agent backend or a
|
||||
new multi-agent integration following `docs/adr/ADR-024-posicionamento-multiagente.md`.
|
||||
Implementation: [`src/emcp_bus/agent_runtime/dispatcher.py`](../../src/emcp_bus/agent_runtime/dispatcher.py),
|
||||
[`agent_runtime/global_supervisor.py`](../../src/emcp_bus/agent_runtime/global_supervisor.py),
|
||||
[`agent_runtime/handoff_graph.py`](../../src/emcp_bus/agent_runtime/handoff_graph.py),
|
||||
[`agent_runtime/identity_resolver.py`](../../src/emcp_bus/agent_runtime/identity_resolver.py),
|
||||
[`agents/example_agent/mode_b_orchestrator_driven/orchestrator.py`](../../agents/example_agent/mode_b_orchestrator_driven/orchestrator.py).
|
||||
See [`AS-BUILT.md`](../AS-BUILT.md) (Agent Runtime, Mode A, Mode B sections).
|
||||
|
||||
## `AgentDispatcher` — the primitive common to both modes
|
||||
|
||||
```python
|
||||
result = await dispatcher.dispatch(nba: NBADecision, arguments: dict[str, Any]) -> DispatchResult
|
||||
```
|
||||
|
||||
`nba` (see `orchestrator/nba_model.py`) carries `agent` — the name of the agent whose credential
|
||||
must be used. `AgentDispatcher` never generates or holds its own credential: it obtains the
|
||||
token via `AgentTokenProvider.token_for(nba.agent)`, typically a real `client_credentials`
|
||||
against the same IdP as any other MCP Client (`OIDCAgentTokenProvider`), and calls
|
||||
`nba.action` through the real Gateway — the same reauthorization as any other `tools/call`
|
||||
(EP-05/EP-03).
|
||||
|
||||
```python
|
||||
DispatchResult(decision_id="...", action="finance.payment.execute", status="allowed" | "denied",
|
||||
structured_content={...} | None, error_text: str | None)
|
||||
```
|
||||
|
||||
A denial from the Gateway/PDP becomes `status="denied"` with `error_text` — `dispatch()` never
|
||||
reinterprets or re-executes around a denial; NBA recalculation after a DENY is the
|
||||
responsibility of a layer above (`orchestrator/fallback.handle_denial`).
|
||||
|
||||
## Mode A — `GlobalSupervisor` (governance-only, ADR-024)
|
||||
|
||||
For when an **external orchestration platform** decides which agent handles the conversation;
|
||||
the Service Orchestrator (EP-11) does not participate.
|
||||
|
||||
```python
|
||||
supervisor = GlobalSupervisor(backends={"sales_agent": ..., "finance_agent": ...})
|
||||
result = await supervisor.route_and_run(
|
||||
message, current_backend="sales_agent", router=keyword_router
|
||||
)
|
||||
# RoutingResult(backend_name, handed_off: bool, turn_result)
|
||||
```
|
||||
|
||||
- `router: RouterFn` — `Callable[[str], str | None]`, chooses a backend name from the
|
||||
message content, or `None` to stay on the current backend. `keyword_router` is the
|
||||
reference implementation (keyword-based, deliberately simple) — **it is not the security
|
||||
boundary**; a wrong routing decision, or an attempt to manipulate routing via prompt, is
|
||||
always recaptured by the real Gateway/PDP at the backend that actually responds.
|
||||
- `BackendAgent` — the only contract a backend needs to satisfy:
|
||||
`async def run_turn(message: str) -> object`. Each `BackendAgent` resolves its **own** MCP
|
||||
Client/entitlement completely independently (token, `tools/list`, reauthorization) —
|
||||
`GlobalSupervisor` never reads, holds, or forwards one backend's credential to another.
|
||||
- `GlobalSupervisor` never imports `emcp_bus.orchestrator` (verified via AST analysis in the
|
||||
corresponding unit test) — mechanical proof that Mode A does not use its own NBA.
|
||||
|
||||
**To integrate a new agent backend in Mode A:** implement `BackendAgent.run_turn`,
|
||||
register its own `ClientRegistration`/`ClientProfile` (see
|
||||
[`identity-oidc.md`](identity-oidc.md)) like any other MCP Client, and add it to the
|
||||
`GlobalSupervisor`'s `backends` dict.
|
||||
|
||||
## Mode B — `ModeBOrchestrator` (full replacement, ADR-024)
|
||||
|
||||
For when **our own Service Orchestrator** is the sole NBA/NBO brain — no external Global
|
||||
Supervisor is consulted.
|
||||
|
||||
```python
|
||||
orchestrator = ModeBOrchestrator(
|
||||
graph=journey_graph,
|
||||
dispatcher=dispatcher,
|
||||
client_ids=["sales-read-agent", "finance-payments-agent"],
|
||||
)
|
||||
decision = orchestrator.decide(subject_id="subject:7f31c2", channel="chat")
|
||||
# ModeBDecision(chosen: NBADecision, considered: list[NBADecision]) | None
|
||||
result = await orchestrator.decide_and_dispatch(subject_id=..., channel=..., arguments={...})
|
||||
```
|
||||
|
||||
`graph` is the real journey `StateGraph` from EP-11-T02 (`orchestrator/graph.py`,
|
||||
`build_journey_graph`), invoked **without modification**, once per `client_id` in `client_ids`,
|
||||
for the same `subject_id` — each invocation resolves that client's profile/entitlement/offerings
|
||||
independently. `_combine` is the reference rule that decides among the resulting
|
||||
`NBADecision`s (a Finance pending item always beats a Sales offer) — it is not a claim of
|
||||
optimality, just the same stance as `RuleBasedNBADecisionModel` (EP-11-T03). A `client_id`
|
||||
whose journey ends with no surviving offer (`nba is None`) simply does not contribute to
|
||||
`considered` — never an error.
|
||||
|
||||
**To extend the combination rule:** replace `ModeBOrchestrator._combine` with a real
|
||||
cross-domain ranking model — the input/output contract (`list[NBADecision]` →
|
||||
`NBADecision | None`) does not change.
|
||||
|
||||
## `execute_handoff`/`build_handoff_payload` (EP-12-T03) — implemented, not used by Modes A/B
|
||||
|
||||
Cross-process handoff primitive with two structurally separated concerns: context minimization
|
||||
(`build_handoff_payload`, only `carry_keys` survives) and always-independent entitlement
|
||||
resolution (`execute_handoff` dispatches via the same `AgentDispatcher`, which resolves the
|
||||
destination agent's credential from scratch). **Real finding**: neither Mode A (pure
|
||||
conversational routing, with no originating `NBADecision`) nor Mode B (already combines
|
||||
decisions and dispatches the winner directly) uses this primitive — see `AS-BUILT.md` for
|
||||
details. It remains correct and reusable for a handoff *within* an Orchestrator-driven journey
|
||||
that needs to hand control to another agent mid-execution.
|
||||
|
||||
## `IdentityResolver` (EP-12-T04) — never an authorization input
|
||||
|
||||
```python
|
||||
resolver = IdentityResolver(IdentityResolverConfig(aliases={"msisdn": "customer_key"}))
|
||||
resolver.resolve(
|
||||
{"msisdn": "5511...", "amount": 100}
|
||||
) # -> {"customer_key": "5511...", "amount": 100}
|
||||
```
|
||||
|
||||
Normalizes field names across channels for the Fabric/MCP parameter mapping — **never**
|
||||
consulted by the PDP (verified via AST in the corresponding unit test, the same discipline as
|
||||
`GlobalSupervisor`). A field resolved from caller-supplied data should never influence an
|
||||
authorization decision (the lesson of `docs/adr/ADR-023-autorizacao-nunca-vem-do-payload.md`).
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception |
|
||||
|---|---|
|
||||
| `nba.agent` without a configured `AgentTokenProvider` | `UnknownAgentError` |
|
||||
| IdP rejects obtaining the agent's token | `AgentCredentialError` |
|
||||
| `current_backend` not registered in `GlobalSupervisor` | `UnknownBackendError` |
|
||||
| Handoff's `nba.agent` diverges from `payload.target_agent` | `ValueError` (`execute_handoff`) |
|
||||
| Channel without a registered `ChannelAdapter` | `UnsupportedChannelError` |
|
||||
125
RI/docs/interfaces/agent-runtime-dispatch.md
Normal file
125
RI/docs/interfaces/agent-runtime-dispatch.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Interface: Agent Runtime dispatch (`AgentDispatcher`, `GlobalSupervisor`, `ModeBOrchestrator`)
|
||||
|
||||
Contratos de despacho do Agent Runtime, para quem vai construir um novo backend de agente ou uma
|
||||
nova integração multiagente seguindo o `docs/adr/ADR-024-posicionamento-multiagente.md`.
|
||||
Implementação: [`src/emcp_bus/agent_runtime/dispatcher.py`](../../src/emcp_bus/agent_runtime/dispatcher.py),
|
||||
[`agent_runtime/global_supervisor.py`](../../src/emcp_bus/agent_runtime/global_supervisor.py),
|
||||
[`agent_runtime/handoff_graph.py`](../../src/emcp_bus/agent_runtime/handoff_graph.py),
|
||||
[`agent_runtime/identity_resolver.py`](../../src/emcp_bus/agent_runtime/identity_resolver.py),
|
||||
[`agents/example_agent/mode_b_orchestrator_driven/orchestrator.py`](../../agents/example_agent/mode_b_orchestrator_driven/orchestrator.py).
|
||||
Ver [`AS-BUILT.md`](../AS-BUILT.md) (seções Agent Runtime, Modo A, Modo B).
|
||||
|
||||
## `AgentDispatcher` — o primitivo comum aos dois modos
|
||||
|
||||
```python
|
||||
result = await dispatcher.dispatch(nba: NBADecision, arguments: dict[str, Any]) -> DispatchResult
|
||||
```
|
||||
|
||||
`nba` (ver `orchestrator/nba_model.py`) carrega `agent` — o nome do agente cuja credencial deve
|
||||
ser usada. `AgentDispatcher` nunca gera ou guarda credencial própria: obtém o token via
|
||||
`AgentTokenProvider.token_for(nba.agent)`, tipicamente um `client_credentials` real contra o
|
||||
mesmo IdP de qualquer outro MCP Client (`OIDCAgentTokenProvider`), e chama `nba.action` através
|
||||
do Gateway real — a mesma reautorização de qualquer outro `tools/call` (EP-05/EP-03).
|
||||
|
||||
```python
|
||||
DispatchResult(decision_id="...", action="finance.payment.execute", status="allowed" | "denied",
|
||||
structured_content={...} | None, error_text: str | None)
|
||||
```
|
||||
|
||||
Uma negação do Gateway/PDP vira `status="denied"` com `error_text` — `dispatch()` nunca
|
||||
reinterpreta ou reexecuta em torno de uma negação; recálculo de NBA após DENY é responsabilidade
|
||||
de uma camada acima (`orchestrator/fallback.handle_denial`).
|
||||
|
||||
## Modo A — `GlobalSupervisor` (governança-somente, ADR-024)
|
||||
|
||||
Para quando uma **plataforma de orquestração externa** decide qual agente atende a conversa; o
|
||||
Service Orchestrator (EP-11) não participa.
|
||||
|
||||
```python
|
||||
supervisor = GlobalSupervisor(backends={"sales_agent": ..., "finance_agent": ...})
|
||||
result = await supervisor.route_and_run(
|
||||
message, current_backend="sales_agent", router=keyword_router
|
||||
)
|
||||
# RoutingResult(backend_name, handed_off: bool, turn_result)
|
||||
```
|
||||
|
||||
- `router: RouterFn` — `Callable[[str], str | None]`, escolhe um nome de backend a partir do
|
||||
conteúdo da mensagem, ou `None` para permanecer no backend atual. `keyword_router` é a
|
||||
implementação de referência (palavra-chave, deliberadamente simples) — **não é a fronteira de
|
||||
segurança**; um roteamento errado, ou uma tentativa de manipular o roteamento via prompt, é
|
||||
sempre recapturado pelo Gateway/PDP real no backend que efetivamente responde.
|
||||
- `BackendAgent` — o único contrato que um backend precisa satisfazer:
|
||||
`async def run_turn(message: str) -> object`. Cada `BackendAgent` resolve seu **próprio** MCP
|
||||
Client/entitlement de forma completamente independente (token, `tools/list`, reautorização) —
|
||||
`GlobalSupervisor` nunca lê, guarda ou repassa a credencial de um backend para outro.
|
||||
- `GlobalSupervisor` nunca importa `emcp_bus.orchestrator` (verificado via análise de AST no
|
||||
teste unitário correspondente) — prova mecânica de que o Modo A não usa NBA própria.
|
||||
|
||||
**Para integrar um novo backend de agente no Modo A:** implementar `BackendAgent.run_turn`,
|
||||
registrar seu próprio `ClientRegistration`/`ClientProfile` (ver
|
||||
[`identity-oidc.md`](identity-oidc.md)) como qualquer outro MCP Client, e adicioná-lo ao dict
|
||||
`backends` do `GlobalSupervisor`.
|
||||
|
||||
## Modo B — `ModeBOrchestrator` (substituição completa, ADR-024)
|
||||
|
||||
Para quando o **nosso próprio Service Orchestrator** é o único cérebro de NBA/NBO — nenhum
|
||||
Global Supervisor externo é consultado.
|
||||
|
||||
```python
|
||||
orchestrator = ModeBOrchestrator(
|
||||
graph=journey_graph,
|
||||
dispatcher=dispatcher,
|
||||
client_ids=["sales-read-agent", "finance-payments-agent"],
|
||||
)
|
||||
decision = orchestrator.decide(subject_id="subject:7f31c2", channel="chat")
|
||||
# ModeBDecision(chosen: NBADecision, considered: list[NBADecision]) | None
|
||||
result = await orchestrator.decide_and_dispatch(subject_id=..., channel=..., arguments={...})
|
||||
```
|
||||
|
||||
`graph` é o journey `StateGraph` real do EP-11-T02 (`orchestrator/graph.py`,
|
||||
`build_journey_graph`), invocado **sem modificação**, uma vez por `client_id` em `client_ids`,
|
||||
para o mesmo `subject_id` — cada invocação resolve profile/entitlement/offerings daquele client
|
||||
de forma independente. `_combine` é a regra de referência que decide entre os `NBADecision`s
|
||||
resultantes (pendência de Finance sempre vence oferta de Sales) — não é uma reivindicação de
|
||||
otimalidade, apenas a mesma postura de `RuleBasedNBADecisionModel` (EP-11-T03). Um `client_id`
|
||||
cuja jornada termina sem oferta sobrevivente (`nba is None`) simplesmente não contribui a
|
||||
`considered` — nunca um erro.
|
||||
|
||||
**Para estender a regra de combinação:** substituir `ModeBOrchestrator._combine` por um modelo
|
||||
de ranking cross-domain real — o contrato de entrada/saída (`list[NBADecision]` →
|
||||
`NBADecision | None`) não muda.
|
||||
|
||||
## `execute_handoff`/`build_handoff_payload` (EP-12-T03) — implementado, não usado pelos modos A/B
|
||||
|
||||
Primitivo de handoff cross-process com dois cuidados estruturalmente separados: minimização de
|
||||
contexto (`build_handoff_payload`, só `carry_keys` sobrevive) e resolução de entitlement sempre
|
||||
independente (`execute_handoff` despacha via o mesmo `AgentDispatcher`, que resolve a credencial
|
||||
do agente destino do zero). **Achado real**: nem o Modo A (roteamento conversacional puro, sem
|
||||
uma `NBADecision` de origem) nem o Modo B (já combina decisões e despacha o vencedor direto)
|
||||
usam este primitivo — ver `AS-BUILT.md` para o detalhe. Continua correto e reutilizável para um
|
||||
handoff *dentro* de uma jornada Orchestrator-driven que precise ceder controle a outro agente no
|
||||
meio da execução.
|
||||
|
||||
## `IdentityResolver` (EP-12-T04) — nunca uma entrada de autorização
|
||||
|
||||
```python
|
||||
resolver = IdentityResolver(IdentityResolverConfig(aliases={"msisdn": "customer_key"}))
|
||||
resolver.resolve(
|
||||
{"msisdn": "5511...", "amount": 100}
|
||||
) # -> {"customer_key": "5511...", "amount": 100}
|
||||
```
|
||||
|
||||
Normaliza nomes de campo entre canais para o mapeamento de parâmetros do Fabric/MCP — **nunca**
|
||||
consultado pelo PDP (verificado via AST no teste unitário correspondente, mesma disciplina do
|
||||
`GlobalSupervisor`). Um campo resolvido a partir de dado fornecido pelo caller nunca deve
|
||||
influenciar uma decisão de autorização (a lição do `docs/adr/ADR-023-autorizacao-nunca-vem-do-payload.md`).
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção |
|
||||
|---|---|
|
||||
| `nba.agent` sem `AgentTokenProvider` configurado | `UnknownAgentError` |
|
||||
| IdP rejeita a obtenção do token do agente | `AgentCredentialError` |
|
||||
| `current_backend` não registrado no `GlobalSupervisor` | `UnknownBackendError` |
|
||||
| `nba.agent` do handoff diverge de `payload.target_agent` | `ValueError` (`execute_handoff`) |
|
||||
| Canal sem `ChannelAdapter` registrado | `UnsupportedChannelError` |
|
||||
76
RI/docs/interfaces/approval-workflow.en-us.md
Normal file
76
RI/docs/interfaces/approval-workflow.en-us.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Interface: approval workflow
|
||||
|
||||
Contract for the approval service, for whoever is going to build a human-approval UI/integration.
|
||||
Implementation: [`src/emcp_bus/approval/models.py`](../../src/emcp_bus/approval/models.py),
|
||||
[`approval/service.py`](../../src/emcp_bus/approval/service.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#approval-workflow-ep-14).
|
||||
|
||||
## When an approval comes into play
|
||||
|
||||
The PDP returns `outcome=REQUIRE_APPROVAL` (see [`pdp-opa.md`](pdp-opa.md)) when a transaction
|
||||
exceeds `ClientProfile.restrictions.approval_threshold`. The Gateway calls
|
||||
`ApprovalService.check_and_consume` on every attempt of that operation; without a granted and
|
||||
still-valid approval, it creates an `ApprovalRequest` and denies the call with
|
||||
`REQUIRE_APPROVAL:<reason_code>:<approval_id>` (see [`mcp-gateway.md`](mcp-gateway.md)).
|
||||
|
||||
## `ApprovalRequest`
|
||||
|
||||
```python
|
||||
ApprovalRequest(
|
||||
approval_id="...", # uuid4
|
||||
operation_hash="...", # sha256(client_profile, tool, canonical arguments)
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments_summary={"amount": 15000, "region": "BR-SP"}, # not redacted in this RI
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
requested_at="2026-09-14T12:00:00Z",
|
||||
expires_at="2026-09-14T12:05:00Z", # ttl_seconds=300 by default
|
||||
status="pending", # pending | granted | denied | consumed
|
||||
)
|
||||
```
|
||||
|
||||
`operation_hash` binds the approval to the **exact triple** `(client_profile, tool, arguments)` —
|
||||
canonical JSON with sorted keys, so the same logical call always produces the same hash
|
||||
regardless of dict insertion order. An approval granted for one call never satisfies a different
|
||||
call with the same `client_profile`/`tool` but different arguments.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```
|
||||
request() → pending
|
||||
grant() → granted (emits an approval_granted audit event)
|
||||
deny() → denied
|
||||
check_and_consume() → True (exactly once) and marks "consumed"; False otherwise
|
||||
```
|
||||
|
||||
`request()` is idempotent per operation: a repeated attempt of the same call while an approval is
|
||||
`pending` returns the same request, and does not create a second one for the approver to
|
||||
evaluate. `check_and_consume()` is the only way an attempt is released, and is single-use by
|
||||
construction — never "enqueue and auto-release later": a `granted` approval that passes
|
||||
`check_and_consume` is immediately marked `consumed` and never satisfies a second attempt, even
|
||||
of the identical operation (README.md §46, "approval replay"). This check is protected by a
|
||||
`threading.Lock` — without it, two genuinely concurrent attempts could both observe `granted`
|
||||
before either wrote `consumed` (a real finding fixed during EP-15-T04, see `AS-BUILT.md`).
|
||||
|
||||
## Building an approval UI/integration
|
||||
|
||||
1. An approver watches for `tool_call_require_approval` events (see
|
||||
[`audit-events.md`](audit-events.md)) — `approval_id` is carried in `payload`.
|
||||
2. The UI/integration calls `ApprovalService.grant(approval_id)` (or `deny`) out of band — today
|
||||
via CLI/direct Python call, there is no HTTP endpoint in this RI (see
|
||||
`../Production-Recommendations.md`).
|
||||
3. The original caller retries the same call (`tools/call` with the same arguments) — the Gateway
|
||||
consults `check_and_consume` again and, now `granted`, releases the execution.
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception |
|
||||
|---|---|
|
||||
| `grant`/`deny` on a nonexistent `approval_id` | `ApprovalNotFoundError` |
|
||||
| Unexpected failure inside `check_and_consume` (any exception) | Handled by the Gateway as a denial — never as an implicit approval (EP-14-T03: `except Exception: approved = False`). |
|
||||
|
||||
## Not implemented in this RI
|
||||
|
||||
Persistent store (today an in-memory `dict` — loses all pending-approval state on a process
|
||||
restart) and a real UI/notification for the approver. See
|
||||
`../Production-Recommendations.md`.
|
||||
77
RI/docs/interfaces/approval-workflow.md
Normal file
77
RI/docs/interfaces/approval-workflow.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Interface: approval workflow
|
||||
|
||||
Contrato do serviço de aprovação, para quem vai construir uma UI/integração de aprovação humana.
|
||||
Implementação: [`src/emcp_bus/approval/models.py`](../../src/emcp_bus/approval/models.py),
|
||||
[`approval/service.py`](../../src/emcp_bus/approval/service.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#approval-workflow-ep-14).
|
||||
|
||||
## Quando uma aprovação entra em jogo
|
||||
|
||||
O PDP retorna `outcome=REQUIRE_APPROVAL` (ver [`pdp-opa.md`](pdp-opa.md)) quando uma transação
|
||||
excede `ClientProfile.restrictions.approval_threshold`. O Gateway chama
|
||||
`ApprovalService.check_and_consume` a cada tentativa dessa operação; sem uma aprovação
|
||||
concedida e ainda válida, cria um `ApprovalRequest` e nega a chamada com
|
||||
`REQUIRE_APPROVAL:<reason_code>:<approval_id>` (ver [`mcp-gateway.md`](mcp-gateway.md)).
|
||||
|
||||
## `ApprovalRequest`
|
||||
|
||||
```python
|
||||
ApprovalRequest(
|
||||
approval_id="...", # uuid4
|
||||
operation_hash="...", # sha256(client_profile, tool, arguments canônicos)
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments_summary={"amount": 15000, "region": "BR-SP"}, # não redigido nesta RI
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
requested_at="2026-09-14T12:00:00Z",
|
||||
expires_at="2026-09-14T12:05:00Z", # ttl_seconds=300 por padrão
|
||||
status="pending", # pending | granted | denied | consumed
|
||||
)
|
||||
```
|
||||
|
||||
`operation_hash` vincula a aprovação ao **triplo exato** `(client_profile, tool, arguments)` —
|
||||
JSON canônico com chaves ordenadas, então a mesma chamada lógica sempre produz o mesmo hash
|
||||
independentemente da ordem de inserção do dict. Uma aprovação concedida para uma chamada nunca
|
||||
satisfaz uma chamada diferente com os mesmos `client_profile`/`tool` mas argumentos distintos.
|
||||
|
||||
## Ciclo de vida
|
||||
|
||||
```
|
||||
request() → pending
|
||||
grant() → granted (emite evento audit approval_granted)
|
||||
deny() → denied
|
||||
check_and_consume() → True (uma única vez) e marca "consumed"; False caso contrário
|
||||
```
|
||||
|
||||
`request()` é idempotente por operação: uma tentativa repetida da mesma chamada enquanto uma
|
||||
aprovação está `pending` retorna a mesma requisição, não cria uma segunda para o approver
|
||||
avaliar. `check_and_consume()` é a única forma de uma tentativa ser liberada, e é single-use por
|
||||
construção — nunca "enfileira e libera automaticamente depois": uma aprovação `granted` que
|
||||
passa em `check_and_consume` é imediatamente marcada `consumed` e nunca satisfaz uma segunda
|
||||
tentativa, mesmo da operação idêntica (README.md §46, "approval replay"). Essa checagem é
|
||||
protegida por `threading.Lock` — sem ele, duas tentativas concorrentes reais poderiam ambas
|
||||
observar `granted` antes de qualquer uma escrever `consumed` (achado real corrigido durante
|
||||
EP-15-T04, ver `AS-BUILT.md`).
|
||||
|
||||
## Construindo uma UI/integração de aprovação
|
||||
|
||||
1. Um approver observa eventos `tool_call_require_approval` (ver
|
||||
[`audit-events.md`](audit-events.md)) — carrega `approval_id` em `payload`.
|
||||
2. A UI/integração chama `ApprovalService.grant(approval_id)` (ou `deny`) fora de banda — hoje
|
||||
via CLI/chamada Python direta, não há endpoint HTTP nesta RI (ver
|
||||
`../Production-Recommendations.md`).
|
||||
3. O caller original retenta a mesma chamada (`tools/call` com os mesmos argumentos) — o Gateway
|
||||
consulta `check_and_consume` de novo e, agora `granted`, libera a execução.
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção |
|
||||
|---|---|
|
||||
| `grant`/`deny` num `approval_id` inexistente | `ApprovalNotFoundError` |
|
||||
| Falha inesperada dentro de `check_and_consume` (qualquer exceção) | Tratada pelo Gateway como negação — nunca como aprovação implícita (EP-14-T03: `except Exception: approved = False`). |
|
||||
|
||||
## Não implementado nesta RI
|
||||
|
||||
Store persistente (hoje um `dict` em memória — perde todo o estado de aprovações pendentes num
|
||||
restart do processo) e uma UI/notificação real para o approver. Ver
|
||||
`../Production-Recommendations.md`.
|
||||
88
RI/docs/interfaces/audit-events.en-us.md
Normal file
88
RI/docs/interfaces/audit-events.en-us.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Interface: audit events
|
||||
|
||||
Taxonomy and schema of audit events, for whoever is going to build a consumer (SIEM,
|
||||
dashboard). Implementation: [`src/emcp_bus/audit/models.py`](../../src/emcp_bus/audit/models.py),
|
||||
[`audit/events.py`](../../src/emcp_bus/audit/events.py),
|
||||
[`audit/sink.py`](../../src/emcp_bus/audit/sink.py),
|
||||
[`audit/correlation.py`](../../src/emcp_bus/audit/correlation.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#auditoria-correlação-e-observabilidade-ep-08).
|
||||
|
||||
## Sink
|
||||
|
||||
`JSONLFileAuditSink` writes one JSON object per line, append-only, to `EMCP_AUDIT_LOG_PATH`
|
||||
(default `/tmp/emcp-audit.jsonl`). A production consumer would replace this with a durable
|
||||
sink (Postgres, queue) — see `../Production-Recommendations.md`; the `EventEnvelope` schema
|
||||
below would not change.
|
||||
|
||||
## `EventEnvelope`
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "3f9a...",
|
||||
"event_type": "tool_call_denied",
|
||||
"category": "grl",
|
||||
"timestamp": "2026-09-14T12:00:00Z",
|
||||
"client_id": "sales-read-agent",
|
||||
"profile_name": "Sales.Read",
|
||||
"tool": "finance.payment.execute",
|
||||
"reason_code": "NOT_IN_ENTITLEMENT",
|
||||
"decision_id": "...",
|
||||
"policy_decision_id": "...",
|
||||
"entitlement_version": "...",
|
||||
"policy_version": "a1b2c3d4e5f6",
|
||||
"mcp_request_id": "...",
|
||||
"trace_id": "...",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
Correlation fields (`decision_id`, `policy_decision_id`, `entitlement_version`,
|
||||
`policy_version`, `mcp_request_id`, `trace_id`) are first-class, never hidden inside
|
||||
`payload` — a consumer can join across events and between events and OTel spans without
|
||||
parsing `payload`.
|
||||
|
||||
## Taxonomy (`category`)
|
||||
|
||||
Adopted from the Agent Platform OCI reference (`docs/adr/ADR-022-referencia-hoshikawa-agent-platform-oci.md`)
|
||||
rather than a taxonomy of its own:
|
||||
|
||||
| Category | Meaning | `event_type` examples |
|
||||
|---|---|---|
|
||||
| `ic` | Customer Interaction / business journey — the call progressed normally | `client_authenticated`, `tool_call_allowed`, `agent_turn_completed`, `nba_recalculated` |
|
||||
| `noc` | Operational/error — an availability failure, not a policy decision | `pdp_unavailable`, `backend_credential_unavailable` |
|
||||
| `grl` | Guardrail/governance — an entitlement/policy boundary was applied or tested | `tool_call_denied`, `tools_list_filtered`, `tool_call_require_approval`, `approval_granted`, `bypass_attempt_detected`, `journey_ended_after_denial` |
|
||||
|
||||
Each `event_type` fixes its own category in the factory that creates it (`audit/events.py`) —
|
||||
never a call-site decision.
|
||||
|
||||
## Sanitization (never optional)
|
||||
|
||||
Any `payload` key, at any nesting level, whose (lowercased) name contains `token`, `secret`,
|
||||
`password`, `authorization`, or `credential` has its value replaced with `sha256:<hex>` — a
|
||||
real secret value never reaches a sink. This runs in
|
||||
`EventEnvelope.model_post_init`, so it applies to every construction path, including
|
||||
deserialization from an existing sink.
|
||||
|
||||
## Available `event_type` values
|
||||
|
||||
| `event_type` | Category | Emitted when |
|
||||
|---|---|---|
|
||||
| `client_authenticated` | ic | A `tools/list` with a valid token resolved a known client. |
|
||||
| `tools_list_filtered` | grl | A `tools/list` returned (includes `allowed`/`total` counts). |
|
||||
| `tool_call_allowed` | ic | A `tools/call` was executed successfully. |
|
||||
| `tool_call_denied` | grl | Any `tools/call` denial (with `reason_code`). |
|
||||
| `tool_call_require_approval` | grl | An attempt generated/encountered a pending `ApprovalRequest`. |
|
||||
| `approval_granted` | grl | An approver granted an approval (`ApprovalService.grant`). |
|
||||
| `pdp_unavailable` | noc | OPA did not respond to a `tools/call` query. |
|
||||
| `backend_credential_unavailable` | noc | The outbound credential for a backend could not be obtained. |
|
||||
| `bypass_attempt_detected` | grl | A request reached a domain server without the Gateway's outbound credential (EP-05-T07). |
|
||||
| `nba_recalculated` | ic | The Orchestrator recalculated the NBA after a DENY from the Gateway (never re-executes the denied action). |
|
||||
| `agent_turn_completed` | ic | An example agent's turn ended — includes `tools_attempted`/`tools_denied` (a tool denied here is the guardrail working, not a bug). |
|
||||
| `journey_ended_after_denial` | grl | No offering survived a DENY — the journey ends, with no direct attempt against the Fabric. |
|
||||
|
||||
## Building a consumer
|
||||
|
||||
A SIEM/dashboard should: read the JSONL (or the equivalent production sink), index by
|
||||
`decision_id`/`mcp_request_id`/`trace_id` for end-to-end reconstruction, and treat any
|
||||
`category: "grl"` event as a guardrail signal (not necessarily an incident — a correct denial
|
||||
is the system working), and `category: "noc"` as an availability signal.
|
||||
88
RI/docs/interfaces/audit-events.md
Normal file
88
RI/docs/interfaces/audit-events.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Interface: eventos de auditoria
|
||||
|
||||
Taxonomia e schema dos eventos de auditoria, para quem vai construir um consumidor (SIEM,
|
||||
dashboard). Implementação: [`src/emcp_bus/audit/models.py`](../../src/emcp_bus/audit/models.py),
|
||||
[`audit/events.py`](../../src/emcp_bus/audit/events.py),
|
||||
[`audit/sink.py`](../../src/emcp_bus/audit/sink.py),
|
||||
[`audit/correlation.py`](../../src/emcp_bus/audit/correlation.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#auditoria-correlação-e-observabilidade-ep-08).
|
||||
|
||||
## Sink
|
||||
|
||||
`JSONLFileAuditSink` grava um objeto JSON por linha, append-only, em `EMCP_AUDIT_LOG_PATH`
|
||||
(padrão `/tmp/emcp-audit.jsonl`). Um consumidor de produção substituiria isso por um sink
|
||||
durável (Postgres, fila) — ver `../Production-Recommendations.md`; o schema do `EventEnvelope`
|
||||
abaixo não mudaria.
|
||||
|
||||
## `EventEnvelope`
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "3f9a...",
|
||||
"event_type": "tool_call_denied",
|
||||
"category": "grl",
|
||||
"timestamp": "2026-09-14T12:00:00Z",
|
||||
"client_id": "sales-read-agent",
|
||||
"profile_name": "Sales.Read",
|
||||
"tool": "finance.payment.execute",
|
||||
"reason_code": "NOT_IN_ENTITLEMENT",
|
||||
"decision_id": "...",
|
||||
"policy_decision_id": "...",
|
||||
"entitlement_version": "...",
|
||||
"policy_version": "a1b2c3d4e5f6",
|
||||
"mcp_request_id": "...",
|
||||
"trace_id": "...",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
Campos de correlação (`decision_id`, `policy_decision_id`, `entitlement_version`,
|
||||
`policy_version`, `mcp_request_id`, `trace_id`) são de primeira classe, nunca escondidos dentro
|
||||
de `payload` — um consumidor pode fazer join entre eventos e entre eventos e spans OTel sem
|
||||
parsear `payload`.
|
||||
|
||||
## Taxonomia (`category`)
|
||||
|
||||
Adotada da referência Agent Platform OCI (`docs/adr/ADR-022-referencia-hoshikawa-agent-platform-oci.md`)
|
||||
em vez de uma taxonomia própria:
|
||||
|
||||
| Categoria | Significado | Exemplos de `event_type` |
|
||||
|---|---|---|
|
||||
| `ic` | Interação de Cliente / negócio-jornada — a chamada progrediu normalmente | `client_authenticated`, `tool_call_allowed`, `agent_turn_completed`, `nba_recalculated` |
|
||||
| `noc` | Operacional/erro — falha de disponibilidade, não uma decisão de política | `pdp_unavailable`, `backend_credential_unavailable` |
|
||||
| `grl` | Guardrail/governança — uma fronteira de entitlement/política foi aplicada ou testada | `tool_call_denied`, `tools_list_filtered`, `tool_call_require_approval`, `approval_granted`, `bypass_attempt_detected`, `journey_ended_after_denial` |
|
||||
|
||||
Cada `event_type` fixa sua própria categoria na fábrica que o cria (`audit/events.py`) — nunca é
|
||||
uma decisão do call site.
|
||||
|
||||
## Sanitização (nunca opcional)
|
||||
|
||||
Qualquer chave de `payload`, em qualquer nível de aninhamento, cujo nome (lowercased) contenha
|
||||
`token`, `secret`, `password`, `authorization` ou `credential` tem seu valor substituído por
|
||||
`sha256:<hex>` — nunca um valor real de segredo chega a um sink. Isso roda em
|
||||
`EventEnvelope.model_post_init`, então vale para toda via de construção, inclusive
|
||||
desserialização a partir de um sink existente.
|
||||
|
||||
## `event_type` disponíveis
|
||||
|
||||
| `event_type` | Categoria | Emitido quando |
|
||||
|---|---|---|
|
||||
| `client_authenticated` | ic | Um `tools/list` com token válido resolveu um client conhecido. |
|
||||
| `tools_list_filtered` | grl | Um `tools/list` retornou (inclui contagem `allowed`/`total`). |
|
||||
| `tool_call_allowed` | ic | Um `tools/call` foi executado com sucesso. |
|
||||
| `tool_call_denied` | grl | Qualquer negação de `tools/call` (com `reason_code`). |
|
||||
| `tool_call_require_approval` | grl | Uma tentativa gerou/encontrou um `ApprovalRequest` pendente. |
|
||||
| `approval_granted` | grl | Um approver concedeu uma aprovação (`ApprovalService.grant`). |
|
||||
| `pdp_unavailable` | noc | O OPA não respondeu a uma consulta de `tools/call`. |
|
||||
| `backend_credential_unavailable` | noc | A credencial de saída para um backend não pôde ser obtida. |
|
||||
| `bypass_attempt_detected` | grl | Uma requisição alcançou um domain server sem a credencial de saída do Gateway (EP-05-T07). |
|
||||
| `nba_recalculated` | ic | O Orchestrator recalculou a NBA após um DENY do Gateway (nunca reexecuta a ação negada). |
|
||||
| `agent_turn_completed` | ic | Um turno do agente de exemplo terminou — inclui `tools_attempted`/`tools_denied` (uma tool negada aqui é o guardrail funcionando, não um bug). |
|
||||
| `journey_ended_after_denial` | grl | Nenhuma offering sobreviveu a um DENY — a jornada termina, sem tentativa direta ao Fabric. |
|
||||
|
||||
## Construindo um consumidor
|
||||
|
||||
Um SIEM/dashboard deveria: ler o JSONL (ou o sink de produção equivalente), indexar por
|
||||
`decision_id`/`mcp_request_id`/`trace_id` para reconstrução ponta a ponta, e tratar qualquer
|
||||
evento `category: "grl"` como um sinal de guardrail (não necessariamente um incidente — negação
|
||||
correta é o sistema funcionando), e `category: "noc"` como sinal de disponibilidade.
|
||||
94
RI/docs/interfaces/downstream-identity.en-us.md
Normal file
94
RI/docs/interfaces/downstream-identity.en-us.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Interface: downstream / outbound identity
|
||||
|
||||
Outbound identity contract, for whoever is going to integrate a new backend/domain server.
|
||||
Implementation: [`src/emcp_bus/downstream/models.py`](../../src/emcp_bus/downstream/models.py),
|
||||
[`downstream/identity.py`](../../src/emcp_bus/downstream/identity.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#downstream--outbound-identity-ep-07).
|
||||
|
||||
Principle: `inbound token (MCP Client → Gateway) ≠ outbound token (Fabric → Backend)`. The Gateway
|
||||
authenticates the *caller*; this contract resolves a separate question — which credential the
|
||||
Fabric presents *to the backend* — and never resolves it by forwarding the inbound token
|
||||
unmodified.
|
||||
|
||||
## Registering a backend (`BackendConfig`)
|
||||
|
||||
One YAML file per backend in `config/backends/*.yaml`:
|
||||
|
||||
```yaml
|
||||
# service_account (default mode of the seed backends)
|
||||
backend: sales-domain
|
||||
url: http://sales-domain:8100/mcp
|
||||
audience: sales-domain-service # never the same as the Gateway's inbound OIDC_AUDIENCE
|
||||
credential_mode: service_account
|
||||
service_account_token_env_var: SALES_DOMAIN_SERVICE_TOKEN # env var name, never a literal value
|
||||
```
|
||||
|
||||
```yaml
|
||||
# token_exchange (available alternative, verified against real Keycloak)
|
||||
backend: finance-domain
|
||||
url: http://finance-domain:8101/mcp
|
||||
audience: finance-domain-service
|
||||
credential_mode: token_exchange
|
||||
token_exchange_endpoint: http://keycloak:8080/realms/emcp/protocol/openid-connect/token
|
||||
token_exchange_scope: aud-finance-domain-service
|
||||
```
|
||||
|
||||
## `service_account` mode
|
||||
|
||||
Static credential, read from an environment variable (name, never value, declared in
|
||||
`service_account_token_env_var`) at the time of each call. Absence of the variable at runtime →
|
||||
`BackendCredentialUnavailableError`.
|
||||
|
||||
## `token_exchange` mode (RFC 8693, Keycloak Standard Token Exchange)
|
||||
|
||||
Two HTTP round trips per new exchange (`TokenExchangeClient.exchange`): (1) `client_credentials`
|
||||
as the Gateway's own exchange client, producing a `subject_token`; (2) exchanging that
|
||||
`subject_token` for a token with the target backend's `audience`. Cached per backend until
|
||||
shortly before the exchange's own `expires_in` — a cache hit costs zero round trips.
|
||||
|
||||
**Required IdP configuration (empirically verified against real Keycloak 26.7.3):**
|
||||
|
||||
- The Gateway's exchange client (`GATEWAY_TOKEN_EXCHANGE_CLIENT_ID`/`_SECRET`) needs
|
||||
`attributes.standard.token.exchange.enabled = "true"` and `serviceAccountsEnabled`.
|
||||
- `audience` must be the id of a client **actually registered** in the realm — a free-form string
|
||||
fails with `"Audience not found"`.
|
||||
- Requesting `audience` without also requesting, via `scope`, a client scope that actually adds
|
||||
that audience fails with `"Requested audience not available"` — the `audience` parameter only
|
||||
**restricts**, never **adds**. That is why `token_exchange_scope` is mandatory in this mode.
|
||||
- That client scope must carry an `oidc-audience-mapper` pointing at the backend's `audience`,
|
||||
and be a default scope (or requested optional scope) of the Gateway's exchange client.
|
||||
|
||||
See `deploy/keycloak/realm-export.json` for a real, already-configured example (`gateway-token-exchange`
|
||||
+ client scopes `aud-sales-domain-service`/`aud-finance-domain-service`).
|
||||
|
||||
## Outbound contract (`BackendCredential`)
|
||||
|
||||
```python
|
||||
BackendCredential(
|
||||
backend="finance-domain",
|
||||
url="http://finance-domain:8101/mcp",
|
||||
token="...",
|
||||
audience="finance-domain-service",
|
||||
)
|
||||
```
|
||||
|
||||
## How to integrate a new backend
|
||||
|
||||
1. Register the backend with the IdP if using `token_exchange` (client scope +
|
||||
`oidc-audience-mapper` for the chosen `audience`) — or simply define the static token's env
|
||||
var if using `service_account`.
|
||||
2. Create `config/backends/<backend>.yaml` (`BackendConfig`).
|
||||
3. Reference `backend.service` == that same id in every `CapabilityManifest` that routes to
|
||||
it (see [`registry-capability-manifest.md`](registry-capability-manifest.md)).
|
||||
4. If the backend is MCP-native (`backend.type: mcp`, the only one with live dispatch in this
|
||||
RI), implement the `BackendCredentialGate` on the backend side (see
|
||||
`services/example_mcp_servers/sales_domain/server.py` as a reference) — a request without the
|
||||
Gateway's outbound credential should never reach a tool handler.
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception | Handling by the caller (Gateway) |
|
||||
|---|---|---|
|
||||
| `backend` without a registered `BackendConfig` | `UnknownBackendError` | `UnroutableCapabilityError` in the Fabric → `UNROUTABLE_CAPABILITY`. |
|
||||
| `service_account` env var missing at runtime | `BackendCredentialUnavailableError` | `backend_credential_unavailable` (NOC event) → call denied. |
|
||||
| RFC 8693 exchange rejected by the IdP | `TokenExchangeError` → re-raised as `BackendCredentialUnavailableError` | Same as above. |
|
||||
93
RI/docs/interfaces/downstream-identity.md
Normal file
93
RI/docs/interfaces/downstream-identity.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Interface: downstream / outbound identity
|
||||
|
||||
Contrato de identidade de saída, para quem vai integrar um novo backend/domain server.
|
||||
Implementação: [`src/emcp_bus/downstream/models.py`](../../src/emcp_bus/downstream/models.py),
|
||||
[`downstream/identity.py`](../../src/emcp_bus/downstream/identity.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#downstream--outbound-identity-ep-07).
|
||||
|
||||
Princípio: `inbound token (MCP Client → Gateway) ≠ outbound token (Fabric → Backend)`. O Gateway
|
||||
autentica o *caller*; este contrato resolve uma pergunta separada — qual credencial o Fabric
|
||||
apresenta *ao backend* — e nunca a resolve repassando o token inbound sem modificação.
|
||||
|
||||
## Registro de um backend (`BackendConfig`)
|
||||
|
||||
Um arquivo YAML por backend em `config/backends/*.yaml`:
|
||||
|
||||
```yaml
|
||||
# service_account (modo padrão dos backends seed)
|
||||
backend: sales-domain
|
||||
url: http://sales-domain:8100/mcp
|
||||
audience: sales-domain-service # nunca igual ao OIDC_AUDIENCE inbound do Gateway
|
||||
credential_mode: service_account
|
||||
service_account_token_env_var: SALES_DOMAIN_SERVICE_TOKEN # nome da env var, nunca um valor literal
|
||||
```
|
||||
|
||||
```yaml
|
||||
# token_exchange (alternativa disponível e verificada contra Keycloak real)
|
||||
backend: finance-domain
|
||||
url: http://finance-domain:8101/mcp
|
||||
audience: finance-domain-service
|
||||
credential_mode: token_exchange
|
||||
token_exchange_endpoint: http://keycloak:8080/realms/emcp/protocol/openid-connect/token
|
||||
token_exchange_scope: aud-finance-domain-service
|
||||
```
|
||||
|
||||
## Modo `service_account`
|
||||
|
||||
Credencial estática, lida de uma variável de ambiente (nome, nunca valor, declarado em
|
||||
`service_account_token_env_var`) no momento de cada chamada. Ausência da variável em runtime →
|
||||
`BackendCredentialUnavailableError`.
|
||||
|
||||
## Modo `token_exchange` (RFC 8693, Keycloak Standard Token Exchange)
|
||||
|
||||
Dois round-trips HTTP por troca nova (`TokenExchangeClient.exchange`): (1) `client_credentials`
|
||||
como o próprio client de exchange do Gateway, produzindo um `subject_token`; (2) troca desse
|
||||
`subject_token` por um token com a `audience` do backend alvo. Cacheado por backend até pouco
|
||||
antes do próprio `expires_in` da troca — um cache hit custa zero round-trips.
|
||||
|
||||
**Configuração exigida no IdP (verificada empiricamente contra Keycloak 26.7.3 real):**
|
||||
|
||||
- O client de exchange do Gateway (`GATEWAY_TOKEN_EXCHANGE_CLIENT_ID`/`_SECRET`) precisa ter
|
||||
`attributes.standard.token.exchange.enabled = "true"` e `serviceAccountsEnabled`.
|
||||
- `audience` precisa ser o id de um client **realmente registrado** no realm — uma string livre
|
||||
falha com `"Audience not found"`.
|
||||
- Pedir `audience` sem também pedir, via `scope`, um client scope que efetivamente adiciona essa
|
||||
audience falha com `"Requested audience not available"` — o parâmetro `audience` só
|
||||
**restringe**, nunca **adiciona**. Por isso `token_exchange_scope` é obrigatório neste modo.
|
||||
- Esse client scope precisa carregar um `oidc-audience-mapper` apontando para a `audience` do
|
||||
backend, e ser um scope padrão (ou opcional solicitado) do client de exchange do Gateway.
|
||||
|
||||
Ver `deploy/keycloak/realm-export.json` para um exemplo real já configurado (`gateway-token-exchange`
|
||||
+ client scopes `aud-sales-domain-service`/`aud-finance-domain-service`).
|
||||
|
||||
## Contrato de saída (`BackendCredential`)
|
||||
|
||||
```python
|
||||
BackendCredential(
|
||||
backend="finance-domain",
|
||||
url="http://finance-domain:8101/mcp",
|
||||
token="...",
|
||||
audience="finance-domain-service",
|
||||
)
|
||||
```
|
||||
|
||||
## Como integrar um novo backend
|
||||
|
||||
1. Registrar o backend no IdP se usar `token_exchange` (client scope + `oidc-audience-mapper`
|
||||
para a `audience` escolhida) — ou apenas definir a env var do token estático se usar
|
||||
`service_account`.
|
||||
2. Criar `config/backends/<backend>.yaml` (`BackendConfig`).
|
||||
3. Referenciar `backend.service` == esse mesmo id em cada `CapabilityManifest` que rotear para
|
||||
ele (ver [`registry-capability-manifest.md`](registry-capability-manifest.md)).
|
||||
4. Se o backend for MCP-nativo (`backend.type: mcp`, o único com dispatch ao vivo nesta RI),
|
||||
implementar o `BackendCredentialGate` do lado do backend (ver
|
||||
`services/example_mcp_servers/sales_domain/server.py` como referência) — uma requisição sem a
|
||||
credencial de saída do Gateway nunca deve alcançar um handler de tool.
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção | Tratamento pelo caller (Gateway) |
|
||||
|---|---|---|
|
||||
| `backend` sem `BackendConfig` registrado | `UnknownBackendError` | `UnroutableCapabilityError` no Fabric → `UNROUTABLE_CAPABILITY`. |
|
||||
| Env var de `service_account` ausente em runtime | `BackendCredentialUnavailableError` | `backend_credential_unavailable` (evento NOC) → chamada negada. |
|
||||
| Troca RFC 8693 rejeitada pelo IdP | `TokenExchangeError` → relançada como `BackendCredentialUnavailableError` | Idem acima. |
|
||||
65
RI/docs/interfaces/identity-oidc.en-us.md
Normal file
65
RI/docs/interfaces/identity-oidc.en-us.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Interface: MCP Client identity and registration (OIDC)
|
||||
|
||||
Contract for registering an MCP Client and the OIDC token it presents to the Gateway.
|
||||
Implementation: [`src/emcp_bus/identity/models.py`](../../src/emcp_bus/identity/models.py),
|
||||
[`identity/authn.py`](../../src/emcp_bus/identity/authn.py),
|
||||
[`identity/shared_client_lint.py`](../../src/emcp_bus/identity/shared_client_lint.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#identidade-do-mcp-client-ep-01).
|
||||
|
||||
## Registering an MCP Client
|
||||
|
||||
One YAML file per client in `config/clients/*.yaml`, validated against `ClientRegistration`:
|
||||
|
||||
```yaml
|
||||
client_id: finance-payments-agent # exactly the token's azp/client_id claim
|
||||
profile: Finance.Payments # name of a ClientProfile under config/clients/profiles/
|
||||
environment: production # "dev" | "ci" | "production"
|
||||
description: "Finance agent authorized to create/execute payments (R3, approval above threshold)"
|
||||
```
|
||||
|
||||
(real example: `config/clients/finance-payments-agent.yaml`)
|
||||
|
||||
`client_id` is the sole link between an authenticated identity and a `ClientProfile` — two
|
||||
distinct `client_id`s never share a `profile` unless that is an explicit and auditable decision
|
||||
(the shared-client rule, `identity/shared_client_lint.py`, wired into
|
||||
`make validate-schemas`).
|
||||
|
||||
## Registering the client with the IdP
|
||||
|
||||
The client must exist in the IdP realm with the `client_credentials` grant enabled and an
|
||||
audience mapper pointing at the Gateway's `OIDC_AUDIENCE` (`emcp-gateway` by default). See
|
||||
`deploy/keycloak/realm-export.json` for the 3 example clients already configured this way
|
||||
(`sales-read-agent`, `sales-write-agent`, `finance-payments-agent`).
|
||||
|
||||
## Access token contract
|
||||
|
||||
Any OIDC-compliant IdP works (`TokenValidator` is provider-agnostic). Claims required and
|
||||
validated by `TokenValidator.validate`:
|
||||
|
||||
| Claim | Use |
|
||||
|---|---|
|
||||
| `exp` | Expiration — required, validated with `leeway_seconds=5`. |
|
||||
| `iss` | Must match `OIDC_ISSUER` exactly. |
|
||||
| `aud` | Must contain `OIDC_AUDIENCE`. |
|
||||
| `azp` (preferred) or `client_id` | Sole source of client identity — never `clientInfo` from the MCP request nor any body field. |
|
||||
|
||||
Signature verified via JWKS (`OIDC_JWKS_URL`), `RS256` algorithm. Failure of any check raises
|
||||
`AuthenticationError`, treated by the Gateway as `UNAUTHENTICATED` (fail-closed).
|
||||
|
||||
## How a new client registers, end to end
|
||||
|
||||
1. Create/update the client in the IdP (`client_credentials` grant, audience mapper).
|
||||
2. Create `config/clients/<client-id>.yaml` (`ClientRegistration`).
|
||||
3. Point `profile` at an existing `ClientProfile`, or create a new one under
|
||||
`config/clients/profiles/` (see [`registry-capability-manifest.md`](registry-capability-manifest.md)
|
||||
for how a capability references a profile via `entitlements`).
|
||||
4. `make validate-schemas`.
|
||||
5. Restart the Gateway (client registrations are loaded at boot, no hot-reload).
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Behavior |
|
||||
|---|---|
|
||||
| Missing/malformed/expired token or invalid signature | `AuthenticationError` → Gateway treats it as `UNAUTHENTICATED`. |
|
||||
| Incorrect `aud`/`iss` | `AuthenticationError` → `UNAUTHENTICATED` (README.md §45 Test 8). |
|
||||
| Valid token, but `client_id` without a matching `ClientRegistration` | `UnknownClientError` → Gateway responds `UNKNOWN_CLIENT` on `tools/call`, empty list on `tools/list`. |
|
||||
65
RI/docs/interfaces/identity-oidc.md
Normal file
65
RI/docs/interfaces/identity-oidc.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Interface: identidade e registro de MCP Client (OIDC)
|
||||
|
||||
Contrato de registro de um MCP Client e do token OIDC que ele apresenta ao Gateway.
|
||||
Implementação: [`src/emcp_bus/identity/models.py`](../../src/emcp_bus/identity/models.py),
|
||||
[`identity/authn.py`](../../src/emcp_bus/identity/authn.py),
|
||||
[`identity/shared_client_lint.py`](../../src/emcp_bus/identity/shared_client_lint.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#identidade-do-mcp-client-ep-01).
|
||||
|
||||
## Registro de um MCP Client
|
||||
|
||||
Um arquivo YAML por client em `config/clients/*.yaml`, validado contra `ClientRegistration`:
|
||||
|
||||
```yaml
|
||||
client_id: finance-payments-agent # exatamente a claim azp/client_id do token
|
||||
profile: Finance.Payments # nome de um ClientProfile em config/clients/profiles/
|
||||
environment: production # "dev" | "ci" | "production"
|
||||
description: "Finance agent authorized to create/execute payments (R3, approval above threshold)"
|
||||
```
|
||||
|
||||
(exemplo real: `config/clients/finance-payments-agent.yaml`)
|
||||
|
||||
`client_id` é o único vínculo entre uma identidade autenticada e um `ClientProfile` — dois
|
||||
`client_id`s distintos nunca compartilham `profile` a menos que isso seja uma decisão explícita
|
||||
e auditável (regra de shared client, `identity/shared_client_lint.py`, ligada a
|
||||
`make validate-schemas`).
|
||||
|
||||
## Registro do client no IdP
|
||||
|
||||
O client precisa existir no realm do IdP com o grant `client_credentials` habilitado e um
|
||||
mapper de audience apontando para o `OIDC_AUDIENCE` do Gateway (`emcp-gateway` por padrão). Ver
|
||||
`deploy/keycloak/realm-export.json` para os 3 clients de exemplo já configurados dessa forma
|
||||
(`sales-read-agent`, `sales-write-agent`, `finance-payments-agent`).
|
||||
|
||||
## Contrato do token de acesso
|
||||
|
||||
Qualquer IdP compatível com OIDC funciona (`TokenValidator` é agnóstico de fornecedor). Claims
|
||||
exigidas e validadas por `TokenValidator.validate`:
|
||||
|
||||
| Claim | Uso |
|
||||
|---|---|
|
||||
| `exp` | Expiração — obrigatória, validada com `leeway_seconds=5`. |
|
||||
| `iss` | Deve casar exatamente com `OIDC_ISSUER`. |
|
||||
| `aud` | Deve conter `OIDC_AUDIENCE`. |
|
||||
| `azp` (preferencial) ou `client_id` | Fonte única de identidade do client — nunca `clientInfo` do request MCP nem qualquer campo do corpo. |
|
||||
|
||||
Assinatura verificada via JWKS (`OIDC_JWKS_URL`), algoritmo `RS256`. Falha em qualquer checagem
|
||||
levanta `AuthenticationError`, tratada pelo Gateway como `UNAUTHENTICATED` (fail-closed).
|
||||
|
||||
## Como um novo client se registra, ponta a ponta
|
||||
|
||||
1. Criar/atualizar o client no IdP (grant `client_credentials`, mapper de audience).
|
||||
2. Criar `config/clients/<client-id>.yaml` (`ClientRegistration`).
|
||||
3. Apontar `profile` para um `ClientProfile` existente, ou criar um novo em
|
||||
`config/clients/profiles/` (ver [`registry-capability-manifest.md`](registry-capability-manifest.md)
|
||||
para como uma capability referencia um profile via `entitlements`).
|
||||
4. `make validate-schemas`.
|
||||
5. Reiniciar o Gateway (registros de client são carregados no boot, sem hot-reload).
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Comportamento |
|
||||
|---|---|
|
||||
| Token ausente/malformado/expirado/assinatura inválida | `AuthenticationError` → Gateway trata como `UNAUTHENTICATED`. |
|
||||
| `aud`/`iss` incorretos | `AuthenticationError` → `UNAUTHENTICATED` (README.md §45 Teste 8). |
|
||||
| Token válido, mas `client_id` sem `ClientRegistration` correspondente | `UnknownClientError` → Gateway responde `UNKNOWN_CLIENT` em `tools/call`, lista vazia em `tools/list`. |
|
||||
90
RI/docs/interfaces/mcp-gateway.en-us.md
Normal file
90
RI/docs/interfaces/mcp-gateway.en-us.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Interface: MCP Gateway
|
||||
|
||||
The Gateway's MCP surface — what any integrating MCP Client calls. Implementation:
|
||||
[`src/emcp_bus/gateway/server.py`](../../src/emcp_bus/gateway/server.py),
|
||||
[`gateway/canonical_request.py`](../../src/emcp_bus/gateway/canonical_request.py),
|
||||
[`gateway/cache.py`](../../src/emcp_bus/gateway/cache.py). See [`AS-BUILT.md`](../AS-BUILT.md#pep--mcp-gateway-ep-05)
|
||||
for the component's role in the architecture.
|
||||
|
||||
## Authentication
|
||||
|
||||
OAuth2/OIDC bearer token in the `Authorization` header. Validated against the JWKS of the configured
|
||||
IdP (`OIDC_JWKS_URL`/`OIDC_ISSUER`/`OIDC_AUDIENCE`) — RS256 signature, `exp`, `iss`, `aud`. The
|
||||
trusted identity is the token's `azp` claim (or, in its absence, `client_id`) — never a
|
||||
`clientInfo` field or any part of the request body.
|
||||
|
||||
## Two deployment modes
|
||||
|
||||
| Mode | Condition | Behavior |
|
||||
|---|---|---|
|
||||
| With OIDC configured | `OIDC_JWKS_URL` + `OIDC_ISSUER` set | The `mcp` SDK's auth middleware rejects any connection without a valid bearer token before the `initialize` handshake. |
|
||||
| Without OIDC configured | Env vars absent (default) | No auth middleware installed; every handler runs with `get_access_token() is None` and denies by default. |
|
||||
|
||||
Both modes are deny-by-default; neither is an "incomplete mode" waiting on the other — see
|
||||
`tests/e2e/test_governed_gateway.py`.
|
||||
|
||||
## `tools/list`
|
||||
|
||||
Without a valid token: returns `tools: []`. With a valid token: returns the intersection of the
|
||||
entitled backends' catalog (`CapabilityRouter.distinct_backends_for`) with the client's
|
||||
`MaximumEntitlement` (`EntitlementManager.resolve(...).allowed_tools`) — never any backend's full
|
||||
catalog. Each call re-evaluates entitlement from scratch; there is no server-side catalog cache
|
||||
between clients.
|
||||
|
||||
**Cache hint (SEP-2549):** the response carries `ttl_ms`/`cache_scope`, always
|
||||
`cache_scope="private"` — never `"public"`, since the result depends on the caller's authorization
|
||||
(`gateway/cache.py:tools_list_cache_hint`). **Verified limitation**: on the classic session
|
||||
transport this RI uses everywhere, the SDK drops `ttl_ms` during serialization (the field is only
|
||||
delivered on the wire by the protocol's 2026-07-28 *stateless per-request* mode) — the client
|
||||
always sees the default (`ttl_ms=0`), which is the most conservative fallback possible, never an
|
||||
unsafe one. The actual security property (never leaking a catalog between clients) does not
|
||||
depend on this — it comes from the Gateway recomputing entitlement on every call.
|
||||
|
||||
## `tools/call`
|
||||
|
||||
Reauthorized completely independently of `tools/list` — the prior list is never consulted to
|
||||
decide whether a call is allowed. Flow:
|
||||
|
||||
1. No valid token → deny (see error table below).
|
||||
2. Unknown client (`ClientRegistration` not found) → deny.
|
||||
3. PDP consulted (`PDPClient.evaluate`, see [`pdp-opa.md`](pdp-opa.md)) with `client_profile`,
|
||||
`tool`, `arguments`.
|
||||
4. `DENY` → deny with the PDP's `reason_code`.
|
||||
5. `REQUIRE_APPROVAL` → consults `ApprovalService.check_and_consume`; without a granted approval,
|
||||
creates an `ApprovalRequest` (see [`approval-workflow.md`](approval-workflow.md)) and denies.
|
||||
6. `ALLOW` → resolves the backend via `CapabilityRouter.route` (see
|
||||
[`registry-capability-manifest.md`](registry-capability-manifest.md)) and executes the call
|
||||
against the domain server, using the outbound credential (never the client's inbound token — see
|
||||
[`downstream-identity.md`](downstream-identity.md)).
|
||||
|
||||
### Denial format
|
||||
|
||||
Every denial is a structured `CallToolResult` (`is_error: true`), never a protocol error:
|
||||
|
||||
```json
|
||||
{"is_error": true, "content": [{"type": "text", "text": "Access denied: <REASON_CODE>"}]}
|
||||
```
|
||||
|
||||
| `REASON_CODE` | When |
|
||||
|---|---|
|
||||
| `UNAUTHENTICATED` | No valid token. |
|
||||
| `UNKNOWN_CLIENT` | Valid token, but `client_id` without a `ClientRegistration`. |
|
||||
| `PDP_UNAVAILABLE` | OPA unreachable — fail-closed, never an implicit ALLOW. |
|
||||
| `NOT_IN_ENTITLEMENT` | The tool is not in `ClientProfile.allowed_tools` (PDP decision). |
|
||||
| `TRANSACTION_POLICY_DENIED` | Entitlement OK, but the arguments violate `transaction.rego` (limit, region, classification). |
|
||||
| `REQUIRE_APPROVAL:<reason>:<approval_id>` | Above the `approval_threshold` — use `approval_id` to track/grant out of band. |
|
||||
| `UNROUTABLE_CAPABILITY` | No `ACTIVE` manifest for the tool, or the backend is not resolvable. |
|
||||
| `UNSUPPORTED_BACKEND_TYPE:<type>` | The manifest points to a `backend.type` with no live dispatch in this RI (e.g. `rest`, see `AS-BUILT.md`). |
|
||||
|
||||
## Header/body consistency (EP-05-T08)
|
||||
|
||||
If the request carries the `Mcp-Method`/`Mcp-Name` headers (SEP-2243), they must agree with the
|
||||
`method`/resource name in the JSON-RPC body, or the request is rejected with HTTP 400
|
||||
(`{"error": "header_body_mismatch", "detail": "..."}`) before reaching authentication. Missing
|
||||
headers are not an error — it's a consistency check, not a mandate to adopt the SEP.
|
||||
|
||||
## Correlation
|
||||
|
||||
Every decision carries, and propagates as OTel span attributes and in every audit event:
|
||||
`decision_id`, `policy_decision_id`, `entitlement_version`, `policy_version`, `mcp_request_id`.
|
||||
See [`audit-events.md`](audit-events.md).
|
||||
90
RI/docs/interfaces/mcp-gateway.md
Normal file
90
RI/docs/interfaces/mcp-gateway.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Interface: MCP Gateway
|
||||
|
||||
A superfície MCP do Gateway — o que qualquer MCP Client integrador chama. Implementação:
|
||||
[`src/emcp_bus/gateway/server.py`](../../src/emcp_bus/gateway/server.py),
|
||||
[`gateway/canonical_request.py`](../../src/emcp_bus/gateway/canonical_request.py),
|
||||
[`gateway/cache.py`](../../src/emcp_bus/gateway/cache.py). Ver [`AS-BUILT.md`](../AS-BUILT.md#pep--mcp-gateway-ep-05)
|
||||
para o papel do componente na arquitetura.
|
||||
|
||||
## Autenticação
|
||||
|
||||
Bearer token OAuth2/OIDC no header `Authorization`. Validado contra o JWKS do IdP configurado
|
||||
(`OIDC_JWKS_URL`/`OIDC_ISSUER`/`OIDC_AUDIENCE`) — assinatura RS256, `exp`, `iss`, `aud`. A
|
||||
identidade confiável é a claim `azp` (ou, na ausência dela, `client_id`) do token — nunca um
|
||||
campo de `clientInfo` ou de qualquer parte do corpo da requisição.
|
||||
|
||||
## Dois modos de deployment
|
||||
|
||||
| Modo | Condição | Comportamento |
|
||||
|---|---|---|
|
||||
| Com OIDC configurado | `OIDC_JWKS_URL` + `OIDC_ISSUER` definidos | O middleware de auth do SDK `mcp` rejeita qualquer conexão sem bearer token válido antes do handshake `initialize`. |
|
||||
| Sem OIDC configurado | Env vars ausentes (padrão) | Nenhum middleware de auth instalado; todo handler roda com `get_access_token() is None` e nega por padrão. |
|
||||
|
||||
Os dois modos são deny-by-default; nenhum é um "modo incompleto" esperando o outro — ver
|
||||
`tests/e2e/test_governed_gateway.py`.
|
||||
|
||||
## `tools/list`
|
||||
|
||||
Sem token válido: retorna `tools: []`. Com token válido: retorna a interseção do catálogo dos
|
||||
backends entitled (`CapabilityRouter.distinct_backends_for`) com o `MaximumEntitlement` do
|
||||
client (`EntitlementManager.resolve(...).allowed_tools`) — nunca o catálogo completo de nenhum
|
||||
backend. Cada chamada reavalia o entitlement do zero; não há cache de catálogo no lado do
|
||||
servidor entre clients.
|
||||
|
||||
**Cache hint (SEP-2549):** a resposta carrega `ttl_ms`/`cache_scope`, sempre
|
||||
`cache_scope="private"` — nunca `"public"`, já que o resultado depende da autorização do
|
||||
caller (`gateway/cache.py:tools_list_cache_hint`). **Limitação verificada**: no transporte de
|
||||
sessão clássica que esta RI usa em toda parte, o SDK descarta `ttl_ms` na serialização (o campo
|
||||
só é entregue no wire pelo modo *stateless per-request* do protocolo 2026-07-28) — o cliente
|
||||
sempre vê o default (`ttl_ms=0`), que é o fallback mais conservador possível, nunca inseguro. A
|
||||
propriedade de segurança real (nunca vazar catálogo entre clientes) não depende disso — vem do
|
||||
Gateway recalcular o entitlement em toda chamada.
|
||||
|
||||
## `tools/call`
|
||||
|
||||
Reautorizado de forma completamente independente de `tools/list` — a lista anterior nunca é
|
||||
consultada para decidir se uma chamada é permitida. Fluxo:
|
||||
|
||||
1. Sem token válido → nega (ver tabela de erros abaixo).
|
||||
2. Client desconhecido (`ClientRegistration` não encontrado) → nega.
|
||||
3. PDP consultado (`PDPClient.evaluate`, ver [`pdp-opa.md`](pdp-opa.md)) com `client_profile`,
|
||||
`tool`, `arguments`.
|
||||
4. `DENY` → nega com o `reason_code` do PDP.
|
||||
5. `REQUIRE_APPROVAL` → consulta `ApprovalService.check_and_consume`; sem aprovação concedida,
|
||||
cria um `ApprovalRequest` (ver [`approval-workflow.md`](approval-workflow.md)) e nega.
|
||||
6. `ALLOW` → resolve o backend via `CapabilityRouter.route` (ver
|
||||
[`registry-capability-manifest.md`](registry-capability-manifest.md)) e executa a chamada
|
||||
contra o domain server, usando a credencial de saída (nunca o token inbound do client — ver
|
||||
[`downstream-identity.md`](downstream-identity.md)).
|
||||
|
||||
### Formato de negação
|
||||
|
||||
Toda negação é um `CallToolResult` estruturado (`is_error: true`), nunca um erro de protocolo:
|
||||
|
||||
```json
|
||||
{"is_error": true, "content": [{"type": "text", "text": "Access denied: <REASON_CODE>"}]}
|
||||
```
|
||||
|
||||
| `REASON_CODE` | Quando |
|
||||
|---|---|
|
||||
| `UNAUTHENTICATED` | Sem token válido. |
|
||||
| `UNKNOWN_CLIENT` | Token válido, mas `client_id` sem `ClientRegistration`. |
|
||||
| `PDP_UNAVAILABLE` | OPA inalcançável — fail-closed, nunca ALLOW implícito. |
|
||||
| `NOT_IN_ENTITLEMENT` | A tool não está em `ClientProfile.allowed_tools` (decisão do PDP). |
|
||||
| `TRANSACTION_POLICY_DENIED` | Entitlement OK, mas os argumentos violam `transaction.rego` (limite, região, classificação). |
|
||||
| `REQUIRE_APPROVAL:<motivo>:<approval_id>` | Acima do `approval_threshold` — usa `approval_id` para acompanhar/conceder fora de banda. |
|
||||
| `UNROUTABLE_CAPABILITY` | Nenhum manifesto `ACTIVE` para a tool, ou backend não resolvível. |
|
||||
| `UNSUPPORTED_BACKEND_TYPE:<tipo>` | O manifesto aponta para um `backend.type` sem dispatch ao vivo nesta RI (ex.: `rest`, ver `AS-BUILT.md`). |
|
||||
|
||||
## Consistência header/body (EP-05-T08)
|
||||
|
||||
Se a requisição carregar os headers `Mcp-Method`/`Mcp-Name` (SEP-2243), eles devem concordar com
|
||||
o `method`/nome do recurso no corpo JSON-RPC, ou a requisição é rejeitada com HTTP 400
|
||||
(`{"error": "header_body_mismatch", "detail": "..."}`) antes de alcançar autenticação. Headers
|
||||
ausentes não são um erro — é uma checagem de consistência, não um mandato de adoção do SEP.
|
||||
|
||||
## Correlação
|
||||
|
||||
Toda decisão carrega, e propaga como atributos de span OTel e em todo evento de auditoria:
|
||||
`decision_id`, `policy_decision_id`, `entitlement_version`, `policy_version`, `mcp_request_id`.
|
||||
Ver [`audit-events.md`](audit-events.md).
|
||||
79
RI/docs/interfaces/pdp-opa.en-us.md
Normal file
79
RI/docs/interfaces/pdp-opa.en-us.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Interface: PDP (OPA)
|
||||
|
||||
Data/query contract for the Policy Decision Point. Implementation:
|
||||
[`src/emcp_bus/pdp/client.py`](../../src/emcp_bus/pdp/client.py),
|
||||
[`pdp/models.py`](../../src/emcp_bus/pdp/models.py),
|
||||
[`config/policies/entitlement.rego`](../../config/policies/entitlement.rego),
|
||||
[`config/policies/transaction.rego`](../../config/policies/transaction.rego). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#pdp--policy-engine-ep-03).
|
||||
|
||||
## Two-step model
|
||||
|
||||
`PDPClient.evaluate(client_profile, tool, arguments, customer_classification)` queries, in
|
||||
order, and never in parallel:
|
||||
|
||||
1. **`emcp.entitlement`** (`entitlement.rego`) — `allow` if `tool` is in
|
||||
`data.emcp.client_profiles[client_profile].allowed_tools`. `DENY` here is final
|
||||
(`reason_code=NOT_IN_ENTITLEMENT`) — no argument is ever evaluated.
|
||||
2. **`emcp.transaction`** (`transaction.rego`) — only evaluated if step 1 allowed. Restricts by
|
||||
argument/resource; can never add a tool absent from the entitlement. Returns `allow` and
|
||||
`require_approval` independently.
|
||||
|
||||
## Versioned data in OPA
|
||||
|
||||
`EntitlementManager.sync_profiles_to_pdp()` pushes each validated `ClientProfile` to
|
||||
`PUT /v1/data/emcp/client_profiles/<profile_name>` at boot — the Rego never reads YAML directly.
|
||||
|
||||
## Evaluation request
|
||||
|
||||
```
|
||||
POST /v1/data/emcp/entitlement/allow
|
||||
{"input": {"client_profile": "Sales.Write", "tool": "finance.payment.execute"}}
|
||||
```
|
||||
|
||||
```
|
||||
POST /v1/data/emcp/transaction
|
||||
{"input": {
|
||||
"client_profile": "Finance.Payments",
|
||||
"tool": "finance.payment.execute",
|
||||
"arguments": {"amount": 15000, "region": "BR-SP"},
|
||||
"customer_classification": "standard"
|
||||
}}
|
||||
```
|
||||
|
||||
## Combined response (`PDPDecision`)
|
||||
|
||||
```json
|
||||
{"outcome": "ALLOW" | "DENY" | "REQUIRE_APPROVAL", "policy_version": "a1b2c3d4e5f6", "reason_code": "..."}
|
||||
```
|
||||
|
||||
Never a lone boolean — `policy_version` and `reason_code` are mandatory in every response.
|
||||
|
||||
| `reason_code` | `outcome` | When |
|
||||
|---|---|---|
|
||||
| `NOT_IN_ENTITLEMENT` | `DENY` | Step 1 denied. |
|
||||
| `TRANSACTION_POLICY_DENIED` | `DENY` | Step 1 allowed, step 2 denied (limit, region, classification). |
|
||||
| `APPROVAL_THRESHOLD_EXCEEDED` | `REQUIRE_APPROVAL` | `allow=true` and `amount > restrictions.approval_threshold`. |
|
||||
| `ALLOWED` | `ALLOW` | Both steps allowed, without exceeding the threshold. |
|
||||
|
||||
## `policy_version`
|
||||
|
||||
A SHA-256 hash (12 characters) of the content of the `*.rego` files (except `*_test.rego`) in
|
||||
`config/policies/`, computed once at Gateway boot (`compute_policy_version`). Propagated in
|
||||
every decision and audit event — see [`audit-events.md`](audit-events.md). A production
|
||||
deployment would replace this with a real OPA bundle revision (see
|
||||
`../Production-Recommendations.md`); the contract (`PDPDecision.policy_version` as an opaque
|
||||
string) would not change.
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception | Handling by the caller (Gateway) |
|
||||
|---|---|---|
|
||||
| OPA unreachable, timeout, malformed response | `PDPUnavailableError` | Treated as `DENY` (`PDP_UNAVAILABLE`) — never an implicit ALLOW (fail-closed, README.md §38). |
|
||||
| Failure to push a `ClientProfile` at boot | `PDPUnavailableError` | The Gateway does not come up "healthy" — there is no degraded mode without entitlement sync. |
|
||||
|
||||
## How a new policy takes effect
|
||||
|
||||
Editing `config/policies/*.rego` changes the computed `policy_version` (content hash). There is
|
||||
no hot-reload in this RI — restart the Gateway (which restarts the associated OPA process, or
|
||||
point it at an already-running OPA with the updated Rego via `opa run --server config/policies/`).
|
||||
79
RI/docs/interfaces/pdp-opa.md
Normal file
79
RI/docs/interfaces/pdp-opa.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Interface: PDP (OPA)
|
||||
|
||||
Contrato de dados/consulta do Policy Decision Point. Implementação:
|
||||
[`src/emcp_bus/pdp/client.py`](../../src/emcp_bus/pdp/client.py),
|
||||
[`pdp/models.py`](../../src/emcp_bus/pdp/models.py),
|
||||
[`config/policies/entitlement.rego`](../../config/policies/entitlement.rego),
|
||||
[`config/policies/transaction.rego`](../../config/policies/transaction.rego). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#pdp--policy-engine-ep-03).
|
||||
|
||||
## Modelo em dois passos
|
||||
|
||||
`PDPClient.evaluate(client_profile, tool, arguments, customer_classification)` consulta, em
|
||||
ordem, e nunca em paralelo:
|
||||
|
||||
1. **`emcp.entitlement`** (`entitlement.rego`) — `allow` se `tool` está em
|
||||
`data.emcp.client_profiles[client_profile].allowed_tools`. `DENY` aqui é definitivo
|
||||
(`reason_code=NOT_IN_ENTITLEMENT`) — nenhum argumento chega a ser avaliado.
|
||||
2. **`emcp.transaction`** (`transaction.rego`) — só avaliada se o passo 1 permitiu. Restringe por
|
||||
argumento/recurso; nunca pode adicionar uma tool ausente do entitlement. Retorna `allow` e
|
||||
`require_approval` independentemente.
|
||||
|
||||
## Dados versionados no OPA
|
||||
|
||||
`EntitlementManager.sync_profiles_to_pdp()` empurra cada `ClientProfile` validado para
|
||||
`PUT /v1/data/emcp/client_profiles/<profile_name>` no boot — o Rego nunca lê YAML diretamente.
|
||||
|
||||
## Requisição de avaliação
|
||||
|
||||
```
|
||||
POST /v1/data/emcp/entitlement/allow
|
||||
{"input": {"client_profile": "Sales.Write", "tool": "finance.payment.execute"}}
|
||||
```
|
||||
|
||||
```
|
||||
POST /v1/data/emcp/transaction
|
||||
{"input": {
|
||||
"client_profile": "Finance.Payments",
|
||||
"tool": "finance.payment.execute",
|
||||
"arguments": {"amount": 15000, "region": "BR-SP"},
|
||||
"customer_classification": "standard"
|
||||
}}
|
||||
```
|
||||
|
||||
## Resposta combinada (`PDPDecision`)
|
||||
|
||||
```json
|
||||
{"outcome": "ALLOW" | "DENY" | "REQUIRE_APPROVAL", "policy_version": "a1b2c3d4e5f6", "reason_code": "..."}
|
||||
```
|
||||
|
||||
Nunca um booleano isolado — `policy_version` e `reason_code` são obrigatórios em toda resposta.
|
||||
|
||||
| `reason_code` | `outcome` | Quando |
|
||||
|---|---|---|
|
||||
| `NOT_IN_ENTITLEMENT` | `DENY` | Passo 1 negou. |
|
||||
| `TRANSACTION_POLICY_DENIED` | `DENY` | Passo 1 permitiu, passo 2 negou (limite, região, classificação). |
|
||||
| `APPROVAL_THRESHOLD_EXCEEDED` | `REQUIRE_APPROVAL` | `allow=true` e `amount > restrictions.approval_threshold`. |
|
||||
| `ALLOWED` | `ALLOW` | Ambos os passos permitiram, sem exceder o threshold. |
|
||||
|
||||
## `policy_version`
|
||||
|
||||
Um hash SHA-256 (12 caracteres) do conteúdo dos arquivos `*.rego` (exceto `*_test.rego`) em
|
||||
`config/policies/`, computado uma vez no boot do Gateway (`compute_policy_version`). Propagado em
|
||||
toda decisão e evento de auditoria — ver [`audit-events.md`](audit-events.md). Um deployment de
|
||||
produção substituiria isso por uma revisão de bundle OPA real (ver
|
||||
`../Production-Recommendations.md`); o contrato (`PDPDecision.policy_version` como string
|
||||
opaca) não mudaria.
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção | Tratamento pelo caller (Gateway) |
|
||||
|---|---|---|
|
||||
| OPA inalcançável, timeout, resposta malformada | `PDPUnavailableError` | Tratado como `DENY` (`PDP_UNAVAILABLE`) — nunca ALLOW implícito (fail-closed, README.md §38). |
|
||||
| Falha ao empurrar um `ClientProfile` no boot | `PDPUnavailableError` | O Gateway não sobe "saudável" — não há modo degradado sem sincronia de entitlement. |
|
||||
|
||||
## Como uma política nova entra em vigor
|
||||
|
||||
Editar `config/policies/*.rego` muda o `policy_version` computado (hash do conteúdo). Não há
|
||||
hot-reload nesta RI — reiniciar o Gateway (que reinicia o processo OPA associado, ou aponta para
|
||||
um OPA já rodando com o Rego atualizado via `opa run --server config/policies/`).
|
||||
92
RI/docs/interfaces/registry-capability-manifest.en-us.md
Normal file
92
RI/docs/interfaces/registry-capability-manifest.en-us.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Interface: capability manifest and Registry lifecycle
|
||||
|
||||
Capability manifest schema and the publishing pipeline, for whoever is going to publish a new
|
||||
capability to the Global Capability Registry. Implementation:
|
||||
[`src/emcp_bus/registry/models.py`](../../src/emcp_bus/registry/models.py),
|
||||
[`registry/pipeline.py`](../../src/emcp_bus/registry/pipeline.py),
|
||||
[`registry/lifecycle.py`](../../src/emcp_bus/registry/lifecycle.py),
|
||||
[`registry/store.py`](../../src/emcp_bus/registry/store.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#global-capability-registry-ep-02).
|
||||
|
||||
## Manifest schema (`CapabilityManifest`)
|
||||
|
||||
```yaml
|
||||
# config/capabilities/finance/payment-execute.yaml
|
||||
name: finance.payment.execute # <domain>.<capability>, validated (domain must prefix name)
|
||||
version: 1.0.0 # SemVer
|
||||
domain: finance
|
||||
owner: finance-platform
|
||||
risk_tier: R3 # R0-R4
|
||||
side_effects: true
|
||||
idempotent: false
|
||||
data_classification: [confidential, restricted] # public | internal | confidential | restricted
|
||||
entitlements: [Finance.Payments] # ClientProfile names that may see/call this capability
|
||||
backend:
|
||||
type: mcp # mcp | rest | grpc (only "mcp" has live dispatch in this RI)
|
||||
service: finance-domain # id resolved by config/backends/<service>.yaml
|
||||
approval:
|
||||
required: true # required if risk_tier is in {R3, R4} — pipeline gate
|
||||
```
|
||||
|
||||
(real example: `config/capabilities/finance/payment-execute.yaml`)
|
||||
|
||||
Fields with defaults: `observability.audit_level` (`"full"`), `lifecycle.status`
|
||||
(`"nominate"` — a new manifest never declares itself `active` directly).
|
||||
|
||||
## Lifecycle (`LifecycleStatus`)
|
||||
|
||||
```
|
||||
NOMINATE → REVIEW → REGISTER → ACTIVE ⇄ ACTIVE → DEPRECATED → RETIRED
|
||||
↑________________|
|
||||
(rejected review goes back to NOMINATE)
|
||||
```
|
||||
|
||||
Only an `ACTIVE` capability is resolvable by the Fabric (`CapabilityRouter`) — any other status
|
||||
is a matter exclusively for the control plane. Nothing reaches `ACTIVE` without passing through
|
||||
`REVIEW` (`registry/lifecycle.py:validate_transition`, mechanically verified).
|
||||
|
||||
## Publishing pipeline
|
||||
|
||||
```python
|
||||
pipeline = PublishingPipeline(store)
|
||||
registered = pipeline.submit(manifest) # runs the gates -> NOMINATE -> REVIEW -> REGISTER
|
||||
pipeline.publish(registered.name) # REGISTER -> ACTIVE
|
||||
```
|
||||
|
||||
`submit()` runs business gates before any state transition (schema/naming is already the
|
||||
responsibility of `CapabilityManifest` itself at construction time):
|
||||
|
||||
| Gate | Rule |
|
||||
|---|---|
|
||||
| Owner | `owner` cannot be empty. |
|
||||
| Backend | `backend.service` cannot be empty. |
|
||||
| Risk-based approval | `risk_tier` in `{R3, R4}` requires `approval.required: true`. |
|
||||
|
||||
Any failed gate raises `PublishingRejectedError(reasons)` — nothing enters the Registry
|
||||
partially (fail-closed, no ad hoc manual registration).
|
||||
|
||||
## How the Fabric resolves a capability
|
||||
|
||||
`CapabilityRouter.route(tool)` looks up `tool`'s `ACTIVE` manifest, resolves
|
||||
`backend.service` to a URL+credential via `BackendCredentialProvider` (see
|
||||
[`downstream-identity.md`](downstream-identity.md)), and returns a `BackendRoute`. Without a
|
||||
matching `ACTIVE` manifest, or an unresolvable backend: `UnroutableCapabilityError` — the Gateway
|
||||
treats this as `UNROUTABLE_CAPABILITY` (DENY).
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception |
|
||||
|---|---|
|
||||
| `name` without a `.` (not `<domain>.<capability>`) | `pydantic.ValidationError` at manifest construction. |
|
||||
| `domain` is not the prefix of `name` | `pydantic.ValidationError` at manifest construction. |
|
||||
| Business gate failed in `submit()` | `PublishingRejectedError` |
|
||||
| Invalid lifecycle transition (e.g. `NOMINATE` → `ACTIVE` directly) | `InvalidLifecycleTransitionError` |
|
||||
| `tool` without an `ACTIVE` manifest, or nonexistent backend | `UnroutableCapabilityError` |
|
||||
|
||||
## Not implemented in this RI
|
||||
|
||||
An administrative HTTP endpoint to publish/revoke via API (today `PublishingPipeline` and the
|
||||
lifecycle transitions are direct Python calls, exercised in tests, not exposed over the
|
||||
network) — see `../Production-Recommendations.md`. A `backend.service` health check as a
|
||||
publishing gate also does not exist (it would be a disguised `TODO` without a real registry of
|
||||
backend health endpoints in this RI).
|
||||
92
RI/docs/interfaces/registry-capability-manifest.md
Normal file
92
RI/docs/interfaces/registry-capability-manifest.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Interface: manifesto de capability e ciclo de vida do Registry
|
||||
|
||||
Schema do manifesto de capability e o pipeline de publicação, para quem vai publicar uma nova
|
||||
capability no Global Capability Registry. Implementação:
|
||||
[`src/emcp_bus/registry/models.py`](../../src/emcp_bus/registry/models.py),
|
||||
[`registry/pipeline.py`](../../src/emcp_bus/registry/pipeline.py),
|
||||
[`registry/lifecycle.py`](../../src/emcp_bus/registry/lifecycle.py),
|
||||
[`registry/store.py`](../../src/emcp_bus/registry/store.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#global-capability-registry-ep-02).
|
||||
|
||||
## Schema do manifesto (`CapabilityManifest`)
|
||||
|
||||
```yaml
|
||||
# config/capabilities/finance/payment-execute.yaml
|
||||
name: finance.payment.execute # <domínio>.<capability>, validado (domain deve prefixar name)
|
||||
version: 1.0.0 # SemVer
|
||||
domain: finance
|
||||
owner: finance-platform
|
||||
risk_tier: R3 # R0-R4
|
||||
side_effects: true
|
||||
idempotent: false
|
||||
data_classification: [confidential, restricted] # public | internal | confidential | restricted
|
||||
entitlements: [Finance.Payments] # nomes de ClientProfile que podem ver/chamar esta capability
|
||||
backend:
|
||||
type: mcp # mcp | rest | grpc (só "mcp" tem dispatch ao vivo nesta RI)
|
||||
service: finance-domain # id resolvido por config/backends/<service>.yaml
|
||||
approval:
|
||||
required: true # obrigatório se risk_tier em {R3, R4} — gate do pipeline
|
||||
```
|
||||
|
||||
(exemplo real: `config/capabilities/finance/payment-execute.yaml`)
|
||||
|
||||
Campos com default: `observability.audit_level` (`"full"`), `lifecycle.status`
|
||||
(`"nominate"` — nunca se declara `active` diretamente num manifesto novo).
|
||||
|
||||
## Ciclo de vida (`LifecycleStatus`)
|
||||
|
||||
```
|
||||
NOMINATE → REVIEW → REGISTER → ACTIVE ⇄ ACTIVE → DEPRECATED → RETIRED
|
||||
↑________________|
|
||||
(review rejeitado volta a NOMINATE)
|
||||
```
|
||||
|
||||
Só uma capability `ACTIVE` é resolvível pelo Fabric (`CapabilityRouter`) — qualquer outro status
|
||||
é um assunto exclusivo do control plane. Nada alcança `ACTIVE` sem passar por `REVIEW`
|
||||
(`registry/lifecycle.py:validate_transition`, verificado mecanicamente).
|
||||
|
||||
## Pipeline de publicação
|
||||
|
||||
```python
|
||||
pipeline = PublishingPipeline(store)
|
||||
registered = pipeline.submit(manifest) # roda os gates -> NOMINATE -> REVIEW -> REGISTER
|
||||
pipeline.publish(registered.name) # REGISTER -> ACTIVE
|
||||
```
|
||||
|
||||
`submit()` roda gates de negócio antes de qualquer transição de estado (schema/naming já é
|
||||
responsabilidade do próprio `CapabilityManifest` na construção):
|
||||
|
||||
| Gate | Regra |
|
||||
|---|---|
|
||||
| Owner | `owner` não pode ser vazio. |
|
||||
| Backend | `backend.service` não pode ser vazio. |
|
||||
| Aprovação por risco | `risk_tier` em `{R3, R4}` exige `approval.required: true`. |
|
||||
|
||||
Qualquer gate reprovado levanta `PublishingRejectedError(reasons)` — nada entra parcialmente no
|
||||
Registry (fail-closed, sem cadastro manual ad hoc).
|
||||
|
||||
## Como o Fabric resolve uma capability
|
||||
|
||||
`CapabilityRouter.route(tool)` busca o manifesto `ACTIVE` de `tool`, resolve
|
||||
`backend.service` para uma URL+credencial via `BackendCredentialProvider` (ver
|
||||
[`downstream-identity.md`](downstream-identity.md)), e retorna um `BackendRoute`. Sem manifesto
|
||||
`ACTIVE` correspondente, ou backend não resolvível: `UnroutableCapabilityError` — o Gateway trata
|
||||
isso como `UNROUTABLE_CAPABILITY` (DENY).
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção |
|
||||
|---|---|
|
||||
| `name` sem `.` (não é `<domínio>.<capability>`) | `pydantic.ValidationError` na construção do manifesto. |
|
||||
| `domain` não é o prefixo de `name` | `pydantic.ValidationError` na construção do manifesto. |
|
||||
| Gate de negócio reprovado em `submit()` | `PublishingRejectedError` |
|
||||
| Transição de lifecycle inválida (ex.: `NOMINATE` → `ACTIVE` direto) | `InvalidLifecycleTransitionError` |
|
||||
| `tool` sem manifesto `ACTIVE`, ou backend inexistente | `UnroutableCapabilityError` |
|
||||
|
||||
## Não implementado nesta RI
|
||||
|
||||
Um endpoint HTTP administrativo para publicar/revogar via API (hoje `PublishingPipeline` e as
|
||||
transições de lifecycle são chamadas Python diretas, exercitadas em teste, não expostas via
|
||||
rede) — ver `../Production-Recommendations.md`. Health check de `backend.service` como gate
|
||||
de publicação também não existe (seria um `TODO` disfarçado sem um registro real de endpoints de
|
||||
saúde de backend nesta RI).
|
||||
@@ -1,6 +1,6 @@
|
||||
"""CI/local check (EP-00-T03): every YAML file under config/ must validate
|
||||
against its Pydantic model -- this is the thing that actually matters
|
||||
(RI-PLANNING.md's "todo YAML de config tem ... validação em CI"), not just
|
||||
("todo YAML de config tem ... validação em CI"), not just
|
||||
that a JSON Schema file exists somewhere.
|
||||
"""
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Read-only tools (risk tier R1, see README.md §17): customer.search,
|
||||
customer.get, order.get, inventory.check. Write tools (R2): quote.create,
|
||||
quote.update. Backed by static mock data -- this server is deliberately
|
||||
"dumb": it never authorizes anything (ADR-023 in docs/RI-PLANNING.md).
|
||||
"dumb": it never authorizes anything (see `docs/adr/ADR-023-autorizacao-nunca-vem-do-payload.md`).
|
||||
Authorization happens exclusively upstream, in the Gateway/PEP (EP-05)
|
||||
before a call ever reaches this process.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Every declarative config type in the RI (client profiles, capability manifests,
|
||||
policies, ...) is loaded through :func:`load_yaml_model`, so secrets never live
|
||||
as literal values in a committed YAML file (see docs/RI-PLANNING.md, EP-00-T05):
|
||||
as literal values in a committed YAML file (EP-00-T05):
|
||||
a value written as ``${VAR_NAME}`` or ``${VAR_NAME:-default}`` is resolved from
|
||||
the environment at load time, and a value that still looks like an unresolved
|
||||
placeholder after interpolation is treated as a configuration error.
|
||||
|
||||
@@ -10,8 +10,8 @@ the `mcp` SDK's built-in `OpenTelemetryMiddleware` (on by default on every
|
||||
`Server`/`MCPServer`): it injects/extracts standard W3C `traceparent` into the
|
||||
JSON-RPC `_meta` field around every request, so a single trace_id spans both
|
||||
processes without any extra instrumentation here -- this is the concrete,
|
||||
checkable form of the M0 acceptance criterion in docs/RI-PLANNING.md ("um
|
||||
único trace_id conecta Gateway -> Fabric -> backend mock"), verified in
|
||||
checkable form of the M0 acceptance criterion ("um único trace_id conecta
|
||||
Gateway -> Fabric -> backend mock"), verified in
|
||||
tests/e2e/test_walking_skeleton.py.
|
||||
|
||||
`setup_tracing` registers one *process-global* TracerProvider via
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Maps an authenticated OAuth2 client identity (the `client_id`/`azp` claim a
|
||||
validated access token carries -- see EP-01-T03) to exactly one client
|
||||
profile (EP-04). This indirection is what lets `docs/RI-PLANNING.md`'s §18
|
||||
profile (EP-04). This indirection is what lets README.md's §18
|
||||
rule ("share a client only if all sharing agents can legitimately carry the
|
||||
same ceiling") be expressed as data instead of code: two client_ids pointing
|
||||
at the same profile are an explicit, auditable sharing decision.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Reference `NBADecisionModel` (EP-11-T03): deterministic rules/priority
|
||||
over `DecisionContext.filtered_offerings` -- not an AI model, not
|
||||
clustering. See `nba_model.py`'s module docstring for the pluggable
|
||||
interface a real ranking model would implement instead, and P7
|
||||
(docs/RI-PLANNING.md) for why this RI doesn't attempt "real" ranking without
|
||||
interface a real ranking model would implement instead, and
|
||||
`RI/docs/Assumptions.md` for why this RI doesn't attempt "real" ranking without
|
||||
a real dataset.
|
||||
"""
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
A deterministic, rules-over-synthetic-attributes stub -- not clustering, not
|
||||
a trained model, not a proprietary taxonomy (P7: this RI does not attempt
|
||||
"real" clustering without a real dataset, see docs/RI-PLANNING.md's gap
|
||||
resolution). `ProfileIntelligenceProvider` is a `Protocol` so a production
|
||||
"real" clustering without a real dataset, see `RI/docs/Assumptions.md`).
|
||||
`ProfileIntelligenceProvider` is a `Protocol` so a production
|
||||
fork can swap in a real ML-backed implementation without touching any
|
||||
consumer (the Offering Filter, EP-10) -- the contract is `ProfileView` in,
|
||||
`ProfileView` out.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Payload-declared identity spoofing (EP-15-T03, docs/RI-PLANNING.md
|
||||
ADR-023 -- a variation of README.md §45 Teste 4 motivated by a real
|
||||
"""Payload-declared identity spoofing (EP-15-T03, ADR-023 --
|
||||
a variation of README.md §45 Teste 4 motivated by a real
|
||||
vulnerability class observed in `docs/research/hoshikawa-agent-platform-oci.md`:
|
||||
an MCP Gateway authorizing tools against an `agent_id` that arrives as
|
||||
**data in the conversation payload**, not as a claim of an independently
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""M0 walking skeleton, now exercised through M1's real enforcement chain
|
||||
(docs/RI-PLANNING.md, Marco M0 + M1: EP-01/EP-03/EP-04/EP-05).
|
||||
(Marco M0 + M1: EP-01/EP-03/EP-04/EP-05).
|
||||
|
||||
Client -> Gateway (real OIDC auth + real OPA-backed entitlement) -> (static
|
||||
routing) -> example Sales MCP server -> response, over real HTTP
|
||||
@@ -101,5 +101,5 @@ async def test_a_single_trace_id_correlates_gateway_and_backend_spans(
|
||||
"Expected at least one SERVER 'tools/call' span for the gateway hop and one "
|
||||
f"for the backend hop, got {len(server_tool_call_spans)}: "
|
||||
f"{[s.name for s in server_tool_call_spans]} -- see Marco M0 acceptance "
|
||||
"criterion in docs/RI-PLANNING.md"
|
||||
"criterion in RI/docs/AS-BUILT.md"
|
||||
)
|
||||
|
||||
1572
docs/RI-PLANNING.md
1572
docs/RI-PLANNING.md
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
# ADR-001: Fronteira de MaximumEntitlement no MCP Client
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-001 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-002: MCP Gateway como PEP unico
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-002 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-003: Reautorizacao independente em tools/call
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-003 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-004: Contexto agentico nunca eleva privilegio
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-004 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-005: Cache privado de tools/list
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-005 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-006: Proibicao de token passthrough
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-006 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-007: Backend nunca exposto diretamente ao client
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-007 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-008: Segmentacao de clients por dominio e risk tier
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-008 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-009: Risk tiers R0-R4 para capabilities
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-009 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-010: Governanca do Global Capability Registry
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-010 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-011: Pipeline obrigatorio de publicacao de capabilities
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-011 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-012: Entitlement Manager como autoridade unica de MaximumEntitlement
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-012 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-013: Contrato plugavel de Profile Intelligence
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-013 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-014: Perfil nunca amplia entitlement
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-014 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-015: NBA como decisao de orquestracao, nao de autorizacao
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-015 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-016: Contrato de dispatch/handoff do Agent Runtime
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-016 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-017: Correlacao ponta a ponta via IDs versionados
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-017 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-018: Stack de implementacao da RI
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-018 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
@@ -21,8 +21,8 @@ Define toda a estrutura de repositório (8.5).
|
||||
|
||||
## Versões confirmadas (EP-16-T04)
|
||||
|
||||
Spike sem dependências, executável a qualquer momento (`docs/RI-PLANNING.md`
|
||||
EP-16-T04): confirmar, imediatamente antes de fixar em `pyproject.toml`/
|
||||
Spike sem dependências, executável a qualquer momento (EP-16-T04): confirmar,
|
||||
imediatamente antes de fixar em `pyproject.toml`/
|
||||
`docker-compose.yml`, os números exatos de versão estável de cada peça da
|
||||
stack — nenhuma versão citada em código/config sem confirmação real
|
||||
(`pip index versions`/download direto/instalação real), nunca de memória.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# ADR-019: MCP Client identity -- production extension point (mTLS / workload identity)
|
||||
|
||||
**Status:** Documented (spike), not implemented in the RI (EP-01-T04, `could`).
|
||||
**Related:** `docs/RI-PLANNING.md` ADR-019 entry (confirmed decision, 2026-09-11), README.md §19, RF-02, Axiom 2.
|
||||
**Related:** this ADR is the canonical record of this decision -- no longer generated from
|
||||
`docs/RI-PLANNING.md` (removed from the repository after M4 completed; history preserved in
|
||||
git). See `RI/docs/AS-BUILT.md` and `RI/docs/interfaces/identity-oidc.md` for implementation and
|
||||
validation context. Also relevant: README.md §19, RF-02, Axiom 2.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-020: Downstream identity: token exchange ou service account isolado
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-020 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-021: Escopo de LangGraph e Langfuse na RI
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-021 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-022: Referencia operacional: Agent Platform OCI (Hoshikawa)
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-022 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-023: Autorizacao de tools nunca vem de campo declarado no payload
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-023 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-024: Posicionamento do E-MCP-BUS frente a plataformas multiagente externas
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-024 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR-025: OPA/Rego como engine do PDP
|
||||
|
||||
**Status:** Aceito.
|
||||
**Fonte:** `docs/RI-PLANNING.md` §8.4 (tabela de ADRs), linha ADR-025 — gerado a partir dela por EP-16-T03. Onde a RI tem um componente/teste concreto implementando a decisão, este documento aponta para ele; a tabela em si continua a referência primária e mais atualizada.
|
||||
**Fonte:** este ADR é o registro canônico desta decisão — não é mais gerado a partir de `docs/RI-PLANNING.md` (removido do repositório após a conclusão do marco M4; histórico preservado no git). Para contexto de implementação e validação, ver `RI/docs/AS-BUILT.md` e, quando aplicável, o contrato relevante em `RI/docs/interfaces/`.
|
||||
|
||||
## Contexto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Pesquisa: Agent Platform OCI (Christiano Hoshikawa) como referência Python/LangGraph/Langfuse
|
||||
|
||||
> Fonte: https://github.com/hoshikawa2/agent_platform_oci (README + SPEC-001, 002, 003, 004, 007, 012, 018). Analisado em 2026-09-11 para fechar as lacunas G2/G8 do [`../RI-PLANNING.md`](../RI-PLANNING.md). Referenciado por ADR-022/ADR-023.
|
||||
> Fonte: https://github.com/hoshikawa2/agent_platform_oci (README + SPEC-001, 002, 003, 004, 007, 012, 018). Analisado em 2026-09-11 para fundamentar as decisões registradas em [`ADR-022`](../adr/ADR-022-referencia-hoshikawa-agent-platform-oci.md)/[`ADR-023`](../adr/ADR-023-autorizacao-nunca-vem-do-payload.md) (ver também [`../../RI/docs/Assumptions.md`](../../RI/docs/Assumptions.md)). Referenciado por ADR-022/ADR-023.
|
||||
>
|
||||
> **Escopo desta pesquisa**: extrair o padrão *operacional* de LangGraph/Langfuse/MCP daquele projeto para a camada de implementação Python da nossa RI. O núcleo de segurança (PEP/PDP/Client-Bound Entitlement) da nossa arquitetura permanece 100% definido pelo `README.md` na raiz deste repositório — nunca pelo projeto pesquisado aqui.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user