revisão e complementação da documentação do projeto

This commit is contained in:
Marcos Lohmann
2026-09-14 14:01:27 -03:00
parent 2fe6d6d508
commit 9a99ec6cc8
65 changed files with 6630 additions and 1854 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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`.

View File

@@ -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
View 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
View 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).

View 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
View 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
View 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
View 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`.

View 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`.

View 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.

View 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` |

View 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` |

View 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`.

View 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`.

View 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.

View 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.

View 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. |

View 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. |

View 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`. |

View 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`. |

View 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).

View 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).

View 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/`).

View 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/`).

View 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).

View 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).

View File

@@ -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.
"""

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.
"""

View File

@@ -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.

View File

@@ -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

View File

@@ -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"
)

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.