mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-05 22:14:11 +00:00
revisão e complementação da documentação do projeto
This commit is contained in:
126
RI/docs/interfaces/agent-runtime-dispatch.en-us.md
Normal file
126
RI/docs/interfaces/agent-runtime-dispatch.en-us.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Interface: Agent Runtime dispatch (`AgentDispatcher`, `GlobalSupervisor`, `ModeBOrchestrator`)
|
||||
|
||||
Agent Runtime dispatch contracts, for whoever is going to build a new agent backend or a
|
||||
new multi-agent integration following `docs/adr/ADR-024-posicionamento-multiagente.md`.
|
||||
Implementation: [`src/emcp_bus/agent_runtime/dispatcher.py`](../../src/emcp_bus/agent_runtime/dispatcher.py),
|
||||
[`agent_runtime/global_supervisor.py`](../../src/emcp_bus/agent_runtime/global_supervisor.py),
|
||||
[`agent_runtime/handoff_graph.py`](../../src/emcp_bus/agent_runtime/handoff_graph.py),
|
||||
[`agent_runtime/identity_resolver.py`](../../src/emcp_bus/agent_runtime/identity_resolver.py),
|
||||
[`agents/example_agent/mode_b_orchestrator_driven/orchestrator.py`](../../agents/example_agent/mode_b_orchestrator_driven/orchestrator.py).
|
||||
See [`AS-BUILT.md`](../AS-BUILT.md) (Agent Runtime, Mode A, Mode B sections).
|
||||
|
||||
## `AgentDispatcher` — the primitive common to both modes
|
||||
|
||||
```python
|
||||
result = await dispatcher.dispatch(nba: NBADecision, arguments: dict[str, Any]) -> DispatchResult
|
||||
```
|
||||
|
||||
`nba` (see `orchestrator/nba_model.py`) carries `agent` — the name of the agent whose credential
|
||||
must be used. `AgentDispatcher` never generates or holds its own credential: it obtains the
|
||||
token via `AgentTokenProvider.token_for(nba.agent)`, typically a real `client_credentials`
|
||||
against the same IdP as any other MCP Client (`OIDCAgentTokenProvider`), and calls
|
||||
`nba.action` through the real Gateway — the same reauthorization as any other `tools/call`
|
||||
(EP-05/EP-03).
|
||||
|
||||
```python
|
||||
DispatchResult(decision_id="...", action="finance.payment.execute", status="allowed" | "denied",
|
||||
structured_content={...} | None, error_text: str | None)
|
||||
```
|
||||
|
||||
A denial from the Gateway/PDP becomes `status="denied"` with `error_text` — `dispatch()` never
|
||||
reinterprets or re-executes around a denial; NBA recalculation after a DENY is the
|
||||
responsibility of a layer above (`orchestrator/fallback.handle_denial`).
|
||||
|
||||
## Mode A — `GlobalSupervisor` (governance-only, ADR-024)
|
||||
|
||||
For when an **external orchestration platform** decides which agent handles the conversation;
|
||||
the Service Orchestrator (EP-11) does not participate.
|
||||
|
||||
```python
|
||||
supervisor = GlobalSupervisor(backends={"sales_agent": ..., "finance_agent": ...})
|
||||
result = await supervisor.route_and_run(
|
||||
message, current_backend="sales_agent", router=keyword_router
|
||||
)
|
||||
# RoutingResult(backend_name, handed_off: bool, turn_result)
|
||||
```
|
||||
|
||||
- `router: RouterFn` — `Callable[[str], str | None]`, chooses a backend name from the
|
||||
message content, or `None` to stay on the current backend. `keyword_router` is the
|
||||
reference implementation (keyword-based, deliberately simple) — **it is not the security
|
||||
boundary**; a wrong routing decision, or an attempt to manipulate routing via prompt, is
|
||||
always recaptured by the real Gateway/PDP at the backend that actually responds.
|
||||
- `BackendAgent` — the only contract a backend needs to satisfy:
|
||||
`async def run_turn(message: str) -> object`. Each `BackendAgent` resolves its **own** MCP
|
||||
Client/entitlement completely independently (token, `tools/list`, reauthorization) —
|
||||
`GlobalSupervisor` never reads, holds, or forwards one backend's credential to another.
|
||||
- `GlobalSupervisor` never imports `emcp_bus.orchestrator` (verified via AST analysis in the
|
||||
corresponding unit test) — mechanical proof that Mode A does not use its own NBA.
|
||||
|
||||
**To integrate a new agent backend in Mode A:** implement `BackendAgent.run_turn`,
|
||||
register its own `ClientRegistration`/`ClientProfile` (see
|
||||
[`identity-oidc.md`](identity-oidc.md)) like any other MCP Client, and add it to the
|
||||
`GlobalSupervisor`'s `backends` dict.
|
||||
|
||||
## Mode B — `ModeBOrchestrator` (full replacement, ADR-024)
|
||||
|
||||
For when **our own Service Orchestrator** is the sole NBA/NBO brain — no external Global
|
||||
Supervisor is consulted.
|
||||
|
||||
```python
|
||||
orchestrator = ModeBOrchestrator(
|
||||
graph=journey_graph,
|
||||
dispatcher=dispatcher,
|
||||
client_ids=["sales-read-agent", "finance-payments-agent"],
|
||||
)
|
||||
decision = orchestrator.decide(subject_id="subject:7f31c2", channel="chat")
|
||||
# ModeBDecision(chosen: NBADecision, considered: list[NBADecision]) | None
|
||||
result = await orchestrator.decide_and_dispatch(subject_id=..., channel=..., arguments={...})
|
||||
```
|
||||
|
||||
`graph` is the real journey `StateGraph` from EP-11-T02 (`orchestrator/graph.py`,
|
||||
`build_journey_graph`), invoked **without modification**, once per `client_id` in `client_ids`,
|
||||
for the same `subject_id` — each invocation resolves that client's profile/entitlement/offerings
|
||||
independently. `_combine` is the reference rule that decides among the resulting
|
||||
`NBADecision`s (a Finance pending item always beats a Sales offer) — it is not a claim of
|
||||
optimality, just the same stance as `RuleBasedNBADecisionModel` (EP-11-T03). A `client_id`
|
||||
whose journey ends with no surviving offer (`nba is None`) simply does not contribute to
|
||||
`considered` — never an error.
|
||||
|
||||
**To extend the combination rule:** replace `ModeBOrchestrator._combine` with a real
|
||||
cross-domain ranking model — the input/output contract (`list[NBADecision]` →
|
||||
`NBADecision | None`) does not change.
|
||||
|
||||
## `execute_handoff`/`build_handoff_payload` (EP-12-T03) — implemented, not used by Modes A/B
|
||||
|
||||
Cross-process handoff primitive with two structurally separated concerns: context minimization
|
||||
(`build_handoff_payload`, only `carry_keys` survives) and always-independent entitlement
|
||||
resolution (`execute_handoff` dispatches via the same `AgentDispatcher`, which resolves the
|
||||
destination agent's credential from scratch). **Real finding**: neither Mode A (pure
|
||||
conversational routing, with no originating `NBADecision`) nor Mode B (already combines
|
||||
decisions and dispatches the winner directly) uses this primitive — see `AS-BUILT.md` for
|
||||
details. It remains correct and reusable for a handoff *within* an Orchestrator-driven journey
|
||||
that needs to hand control to another agent mid-execution.
|
||||
|
||||
## `IdentityResolver` (EP-12-T04) — never an authorization input
|
||||
|
||||
```python
|
||||
resolver = IdentityResolver(IdentityResolverConfig(aliases={"msisdn": "customer_key"}))
|
||||
resolver.resolve(
|
||||
{"msisdn": "5511...", "amount": 100}
|
||||
) # -> {"customer_key": "5511...", "amount": 100}
|
||||
```
|
||||
|
||||
Normalizes field names across channels for the Fabric/MCP parameter mapping — **never**
|
||||
consulted by the PDP (verified via AST in the corresponding unit test, the same discipline as
|
||||
`GlobalSupervisor`). A field resolved from caller-supplied data should never influence an
|
||||
authorization decision (the lesson of `docs/adr/ADR-023-autorizacao-nunca-vem-do-payload.md`).
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception |
|
||||
|---|---|
|
||||
| `nba.agent` without a configured `AgentTokenProvider` | `UnknownAgentError` |
|
||||
| IdP rejects obtaining the agent's token | `AgentCredentialError` |
|
||||
| `current_backend` not registered in `GlobalSupervisor` | `UnknownBackendError` |
|
||||
| Handoff's `nba.agent` diverges from `payload.target_agent` | `ValueError` (`execute_handoff`) |
|
||||
| Channel without a registered `ChannelAdapter` | `UnsupportedChannelError` |
|
||||
125
RI/docs/interfaces/agent-runtime-dispatch.md
Normal file
125
RI/docs/interfaces/agent-runtime-dispatch.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Interface: Agent Runtime dispatch (`AgentDispatcher`, `GlobalSupervisor`, `ModeBOrchestrator`)
|
||||
|
||||
Contratos de despacho do Agent Runtime, para quem vai construir um novo backend de agente ou uma
|
||||
nova integração multiagente seguindo o `docs/adr/ADR-024-posicionamento-multiagente.md`.
|
||||
Implementação: [`src/emcp_bus/agent_runtime/dispatcher.py`](../../src/emcp_bus/agent_runtime/dispatcher.py),
|
||||
[`agent_runtime/global_supervisor.py`](../../src/emcp_bus/agent_runtime/global_supervisor.py),
|
||||
[`agent_runtime/handoff_graph.py`](../../src/emcp_bus/agent_runtime/handoff_graph.py),
|
||||
[`agent_runtime/identity_resolver.py`](../../src/emcp_bus/agent_runtime/identity_resolver.py),
|
||||
[`agents/example_agent/mode_b_orchestrator_driven/orchestrator.py`](../../agents/example_agent/mode_b_orchestrator_driven/orchestrator.py).
|
||||
Ver [`AS-BUILT.md`](../AS-BUILT.md) (seções Agent Runtime, Modo A, Modo B).
|
||||
|
||||
## `AgentDispatcher` — o primitivo comum aos dois modos
|
||||
|
||||
```python
|
||||
result = await dispatcher.dispatch(nba: NBADecision, arguments: dict[str, Any]) -> DispatchResult
|
||||
```
|
||||
|
||||
`nba` (ver `orchestrator/nba_model.py`) carrega `agent` — o nome do agente cuja credencial deve
|
||||
ser usada. `AgentDispatcher` nunca gera ou guarda credencial própria: obtém o token via
|
||||
`AgentTokenProvider.token_for(nba.agent)`, tipicamente um `client_credentials` real contra o
|
||||
mesmo IdP de qualquer outro MCP Client (`OIDCAgentTokenProvider`), e chama `nba.action` através
|
||||
do Gateway real — a mesma reautorização de qualquer outro `tools/call` (EP-05/EP-03).
|
||||
|
||||
```python
|
||||
DispatchResult(decision_id="...", action="finance.payment.execute", status="allowed" | "denied",
|
||||
structured_content={...} | None, error_text: str | None)
|
||||
```
|
||||
|
||||
Uma negação do Gateway/PDP vira `status="denied"` com `error_text` — `dispatch()` nunca
|
||||
reinterpreta ou reexecuta em torno de uma negação; recálculo de NBA após DENY é responsabilidade
|
||||
de uma camada acima (`orchestrator/fallback.handle_denial`).
|
||||
|
||||
## Modo A — `GlobalSupervisor` (governança-somente, ADR-024)
|
||||
|
||||
Para quando uma **plataforma de orquestração externa** decide qual agente atende a conversa; o
|
||||
Service Orchestrator (EP-11) não participa.
|
||||
|
||||
```python
|
||||
supervisor = GlobalSupervisor(backends={"sales_agent": ..., "finance_agent": ...})
|
||||
result = await supervisor.route_and_run(
|
||||
message, current_backend="sales_agent", router=keyword_router
|
||||
)
|
||||
# RoutingResult(backend_name, handed_off: bool, turn_result)
|
||||
```
|
||||
|
||||
- `router: RouterFn` — `Callable[[str], str | None]`, escolhe um nome de backend a partir do
|
||||
conteúdo da mensagem, ou `None` para permanecer no backend atual. `keyword_router` é a
|
||||
implementação de referência (palavra-chave, deliberadamente simples) — **não é a fronteira de
|
||||
segurança**; um roteamento errado, ou uma tentativa de manipular o roteamento via prompt, é
|
||||
sempre recapturado pelo Gateway/PDP real no backend que efetivamente responde.
|
||||
- `BackendAgent` — o único contrato que um backend precisa satisfazer:
|
||||
`async def run_turn(message: str) -> object`. Cada `BackendAgent` resolve seu **próprio** MCP
|
||||
Client/entitlement de forma completamente independente (token, `tools/list`, reautorização) —
|
||||
`GlobalSupervisor` nunca lê, guarda ou repassa a credencial de um backend para outro.
|
||||
- `GlobalSupervisor` nunca importa `emcp_bus.orchestrator` (verificado via análise de AST no
|
||||
teste unitário correspondente) — prova mecânica de que o Modo A não usa NBA própria.
|
||||
|
||||
**Para integrar um novo backend de agente no Modo A:** implementar `BackendAgent.run_turn`,
|
||||
registrar seu próprio `ClientRegistration`/`ClientProfile` (ver
|
||||
[`identity-oidc.md`](identity-oidc.md)) como qualquer outro MCP Client, e adicioná-lo ao dict
|
||||
`backends` do `GlobalSupervisor`.
|
||||
|
||||
## Modo B — `ModeBOrchestrator` (substituição completa, ADR-024)
|
||||
|
||||
Para quando o **nosso próprio Service Orchestrator** é o único cérebro de NBA/NBO — nenhum
|
||||
Global Supervisor externo é consultado.
|
||||
|
||||
```python
|
||||
orchestrator = ModeBOrchestrator(
|
||||
graph=journey_graph,
|
||||
dispatcher=dispatcher,
|
||||
client_ids=["sales-read-agent", "finance-payments-agent"],
|
||||
)
|
||||
decision = orchestrator.decide(subject_id="subject:7f31c2", channel="chat")
|
||||
# ModeBDecision(chosen: NBADecision, considered: list[NBADecision]) | None
|
||||
result = await orchestrator.decide_and_dispatch(subject_id=..., channel=..., arguments={...})
|
||||
```
|
||||
|
||||
`graph` é o journey `StateGraph` real do EP-11-T02 (`orchestrator/graph.py`,
|
||||
`build_journey_graph`), invocado **sem modificação**, uma vez por `client_id` em `client_ids`,
|
||||
para o mesmo `subject_id` — cada invocação resolve profile/entitlement/offerings daquele client
|
||||
de forma independente. `_combine` é a regra de referência que decide entre os `NBADecision`s
|
||||
resultantes (pendência de Finance sempre vence oferta de Sales) — não é uma reivindicação de
|
||||
otimalidade, apenas a mesma postura de `RuleBasedNBADecisionModel` (EP-11-T03). Um `client_id`
|
||||
cuja jornada termina sem oferta sobrevivente (`nba is None`) simplesmente não contribui a
|
||||
`considered` — nunca um erro.
|
||||
|
||||
**Para estender a regra de combinação:** substituir `ModeBOrchestrator._combine` por um modelo
|
||||
de ranking cross-domain real — o contrato de entrada/saída (`list[NBADecision]` →
|
||||
`NBADecision | None`) não muda.
|
||||
|
||||
## `execute_handoff`/`build_handoff_payload` (EP-12-T03) — implementado, não usado pelos modos A/B
|
||||
|
||||
Primitivo de handoff cross-process com dois cuidados estruturalmente separados: minimização de
|
||||
contexto (`build_handoff_payload`, só `carry_keys` sobrevive) e resolução de entitlement sempre
|
||||
independente (`execute_handoff` despacha via o mesmo `AgentDispatcher`, que resolve a credencial
|
||||
do agente destino do zero). **Achado real**: nem o Modo A (roteamento conversacional puro, sem
|
||||
uma `NBADecision` de origem) nem o Modo B (já combina decisões e despacha o vencedor direto)
|
||||
usam este primitivo — ver `AS-BUILT.md` para o detalhe. Continua correto e reutilizável para um
|
||||
handoff *dentro* de uma jornada Orchestrator-driven que precise ceder controle a outro agente no
|
||||
meio da execução.
|
||||
|
||||
## `IdentityResolver` (EP-12-T04) — nunca uma entrada de autorização
|
||||
|
||||
```python
|
||||
resolver = IdentityResolver(IdentityResolverConfig(aliases={"msisdn": "customer_key"}))
|
||||
resolver.resolve(
|
||||
{"msisdn": "5511...", "amount": 100}
|
||||
) # -> {"customer_key": "5511...", "amount": 100}
|
||||
```
|
||||
|
||||
Normaliza nomes de campo entre canais para o mapeamento de parâmetros do Fabric/MCP — **nunca**
|
||||
consultado pelo PDP (verificado via AST no teste unitário correspondente, mesma disciplina do
|
||||
`GlobalSupervisor`). Um campo resolvido a partir de dado fornecido pelo caller nunca deve
|
||||
influenciar uma decisão de autorização (a lição do `docs/adr/ADR-023-autorizacao-nunca-vem-do-payload.md`).
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção |
|
||||
|---|---|
|
||||
| `nba.agent` sem `AgentTokenProvider` configurado | `UnknownAgentError` |
|
||||
| IdP rejeita a obtenção do token do agente | `AgentCredentialError` |
|
||||
| `current_backend` não registrado no `GlobalSupervisor` | `UnknownBackendError` |
|
||||
| `nba.agent` do handoff diverge de `payload.target_agent` | `ValueError` (`execute_handoff`) |
|
||||
| Canal sem `ChannelAdapter` registrado | `UnsupportedChannelError` |
|
||||
76
RI/docs/interfaces/approval-workflow.en-us.md
Normal file
76
RI/docs/interfaces/approval-workflow.en-us.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Interface: approval workflow
|
||||
|
||||
Contract for the approval service, for whoever is going to build a human-approval UI/integration.
|
||||
Implementation: [`src/emcp_bus/approval/models.py`](../../src/emcp_bus/approval/models.py),
|
||||
[`approval/service.py`](../../src/emcp_bus/approval/service.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#approval-workflow-ep-14).
|
||||
|
||||
## When an approval comes into play
|
||||
|
||||
The PDP returns `outcome=REQUIRE_APPROVAL` (see [`pdp-opa.md`](pdp-opa.md)) when a transaction
|
||||
exceeds `ClientProfile.restrictions.approval_threshold`. The Gateway calls
|
||||
`ApprovalService.check_and_consume` on every attempt of that operation; without a granted and
|
||||
still-valid approval, it creates an `ApprovalRequest` and denies the call with
|
||||
`REQUIRE_APPROVAL:<reason_code>:<approval_id>` (see [`mcp-gateway.md`](mcp-gateway.md)).
|
||||
|
||||
## `ApprovalRequest`
|
||||
|
||||
```python
|
||||
ApprovalRequest(
|
||||
approval_id="...", # uuid4
|
||||
operation_hash="...", # sha256(client_profile, tool, canonical arguments)
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments_summary={"amount": 15000, "region": "BR-SP"}, # not redacted in this RI
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
requested_at="2026-09-14T12:00:00Z",
|
||||
expires_at="2026-09-14T12:05:00Z", # ttl_seconds=300 by default
|
||||
status="pending", # pending | granted | denied | consumed
|
||||
)
|
||||
```
|
||||
|
||||
`operation_hash` binds the approval to the **exact triple** `(client_profile, tool, arguments)` —
|
||||
canonical JSON with sorted keys, so the same logical call always produces the same hash
|
||||
regardless of dict insertion order. An approval granted for one call never satisfies a different
|
||||
call with the same `client_profile`/`tool` but different arguments.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```
|
||||
request() → pending
|
||||
grant() → granted (emits an approval_granted audit event)
|
||||
deny() → denied
|
||||
check_and_consume() → True (exactly once) and marks "consumed"; False otherwise
|
||||
```
|
||||
|
||||
`request()` is idempotent per operation: a repeated attempt of the same call while an approval is
|
||||
`pending` returns the same request, and does not create a second one for the approver to
|
||||
evaluate. `check_and_consume()` is the only way an attempt is released, and is single-use by
|
||||
construction — never "enqueue and auto-release later": a `granted` approval that passes
|
||||
`check_and_consume` is immediately marked `consumed` and never satisfies a second attempt, even
|
||||
of the identical operation (README.md §46, "approval replay"). This check is protected by a
|
||||
`threading.Lock` — without it, two genuinely concurrent attempts could both observe `granted`
|
||||
before either wrote `consumed` (a real finding fixed during EP-15-T04, see `AS-BUILT.md`).
|
||||
|
||||
## Building an approval UI/integration
|
||||
|
||||
1. An approver watches for `tool_call_require_approval` events (see
|
||||
[`audit-events.md`](audit-events.md)) — `approval_id` is carried in `payload`.
|
||||
2. The UI/integration calls `ApprovalService.grant(approval_id)` (or `deny`) out of band — today
|
||||
via CLI/direct Python call, there is no HTTP endpoint in this RI (see
|
||||
`../Production-Recommendations.md`).
|
||||
3. The original caller retries the same call (`tools/call` with the same arguments) — the Gateway
|
||||
consults `check_and_consume` again and, now `granted`, releases the execution.
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception |
|
||||
|---|---|
|
||||
| `grant`/`deny` on a nonexistent `approval_id` | `ApprovalNotFoundError` |
|
||||
| Unexpected failure inside `check_and_consume` (any exception) | Handled by the Gateway as a denial — never as an implicit approval (EP-14-T03: `except Exception: approved = False`). |
|
||||
|
||||
## Not implemented in this RI
|
||||
|
||||
Persistent store (today an in-memory `dict` — loses all pending-approval state on a process
|
||||
restart) and a real UI/notification for the approver. See
|
||||
`../Production-Recommendations.md`.
|
||||
77
RI/docs/interfaces/approval-workflow.md
Normal file
77
RI/docs/interfaces/approval-workflow.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Interface: approval workflow
|
||||
|
||||
Contrato do serviço de aprovação, para quem vai construir uma UI/integração de aprovação humana.
|
||||
Implementação: [`src/emcp_bus/approval/models.py`](../../src/emcp_bus/approval/models.py),
|
||||
[`approval/service.py`](../../src/emcp_bus/approval/service.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#approval-workflow-ep-14).
|
||||
|
||||
## Quando uma aprovação entra em jogo
|
||||
|
||||
O PDP retorna `outcome=REQUIRE_APPROVAL` (ver [`pdp-opa.md`](pdp-opa.md)) quando uma transação
|
||||
excede `ClientProfile.restrictions.approval_threshold`. O Gateway chama
|
||||
`ApprovalService.check_and_consume` a cada tentativa dessa operação; sem uma aprovação
|
||||
concedida e ainda válida, cria um `ApprovalRequest` e nega a chamada com
|
||||
`REQUIRE_APPROVAL:<reason_code>:<approval_id>` (ver [`mcp-gateway.md`](mcp-gateway.md)).
|
||||
|
||||
## `ApprovalRequest`
|
||||
|
||||
```python
|
||||
ApprovalRequest(
|
||||
approval_id="...", # uuid4
|
||||
operation_hash="...", # sha256(client_profile, tool, arguments canônicos)
|
||||
client_profile="Finance.Payments",
|
||||
tool="finance.payment.execute",
|
||||
arguments_summary={"amount": 15000, "region": "BR-SP"}, # não redigido nesta RI
|
||||
reason_code="APPROVAL_THRESHOLD_EXCEEDED",
|
||||
requested_at="2026-09-14T12:00:00Z",
|
||||
expires_at="2026-09-14T12:05:00Z", # ttl_seconds=300 por padrão
|
||||
status="pending", # pending | granted | denied | consumed
|
||||
)
|
||||
```
|
||||
|
||||
`operation_hash` vincula a aprovação ao **triplo exato** `(client_profile, tool, arguments)` —
|
||||
JSON canônico com chaves ordenadas, então a mesma chamada lógica sempre produz o mesmo hash
|
||||
independentemente da ordem de inserção do dict. Uma aprovação concedida para uma chamada nunca
|
||||
satisfaz uma chamada diferente com os mesmos `client_profile`/`tool` mas argumentos distintos.
|
||||
|
||||
## Ciclo de vida
|
||||
|
||||
```
|
||||
request() → pending
|
||||
grant() → granted (emite evento audit approval_granted)
|
||||
deny() → denied
|
||||
check_and_consume() → True (uma única vez) e marca "consumed"; False caso contrário
|
||||
```
|
||||
|
||||
`request()` é idempotente por operação: uma tentativa repetida da mesma chamada enquanto uma
|
||||
aprovação está `pending` retorna a mesma requisição, não cria uma segunda para o approver
|
||||
avaliar. `check_and_consume()` é a única forma de uma tentativa ser liberada, e é single-use por
|
||||
construção — nunca "enfileira e libera automaticamente depois": uma aprovação `granted` que
|
||||
passa em `check_and_consume` é imediatamente marcada `consumed` e nunca satisfaz uma segunda
|
||||
tentativa, mesmo da operação idêntica (README.md §46, "approval replay"). Essa checagem é
|
||||
protegida por `threading.Lock` — sem ele, duas tentativas concorrentes reais poderiam ambas
|
||||
observar `granted` antes de qualquer uma escrever `consumed` (achado real corrigido durante
|
||||
EP-15-T04, ver `AS-BUILT.md`).
|
||||
|
||||
## Construindo uma UI/integração de aprovação
|
||||
|
||||
1. Um approver observa eventos `tool_call_require_approval` (ver
|
||||
[`audit-events.md`](audit-events.md)) — carrega `approval_id` em `payload`.
|
||||
2. A UI/integração chama `ApprovalService.grant(approval_id)` (ou `deny`) fora de banda — hoje
|
||||
via CLI/chamada Python direta, não há endpoint HTTP nesta RI (ver
|
||||
`../Production-Recommendations.md`).
|
||||
3. O caller original retenta a mesma chamada (`tools/call` com os mesmos argumentos) — o Gateway
|
||||
consulta `check_and_consume` de novo e, agora `granted`, libera a execução.
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção |
|
||||
|---|---|
|
||||
| `grant`/`deny` num `approval_id` inexistente | `ApprovalNotFoundError` |
|
||||
| Falha inesperada dentro de `check_and_consume` (qualquer exceção) | Tratada pelo Gateway como negação — nunca como aprovação implícita (EP-14-T03: `except Exception: approved = False`). |
|
||||
|
||||
## Não implementado nesta RI
|
||||
|
||||
Store persistente (hoje um `dict` em memória — perde todo o estado de aprovações pendentes num
|
||||
restart do processo) e uma UI/notificação real para o approver. Ver
|
||||
`../Production-Recommendations.md`.
|
||||
88
RI/docs/interfaces/audit-events.en-us.md
Normal file
88
RI/docs/interfaces/audit-events.en-us.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Interface: audit events
|
||||
|
||||
Taxonomy and schema of audit events, for whoever is going to build a consumer (SIEM,
|
||||
dashboard). Implementation: [`src/emcp_bus/audit/models.py`](../../src/emcp_bus/audit/models.py),
|
||||
[`audit/events.py`](../../src/emcp_bus/audit/events.py),
|
||||
[`audit/sink.py`](../../src/emcp_bus/audit/sink.py),
|
||||
[`audit/correlation.py`](../../src/emcp_bus/audit/correlation.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#auditoria-correlação-e-observabilidade-ep-08).
|
||||
|
||||
## Sink
|
||||
|
||||
`JSONLFileAuditSink` writes one JSON object per line, append-only, to `EMCP_AUDIT_LOG_PATH`
|
||||
(default `/tmp/emcp-audit.jsonl`). A production consumer would replace this with a durable
|
||||
sink (Postgres, queue) — see `../Production-Recommendations.md`; the `EventEnvelope` schema
|
||||
below would not change.
|
||||
|
||||
## `EventEnvelope`
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "3f9a...",
|
||||
"event_type": "tool_call_denied",
|
||||
"category": "grl",
|
||||
"timestamp": "2026-09-14T12:00:00Z",
|
||||
"client_id": "sales-read-agent",
|
||||
"profile_name": "Sales.Read",
|
||||
"tool": "finance.payment.execute",
|
||||
"reason_code": "NOT_IN_ENTITLEMENT",
|
||||
"decision_id": "...",
|
||||
"policy_decision_id": "...",
|
||||
"entitlement_version": "...",
|
||||
"policy_version": "a1b2c3d4e5f6",
|
||||
"mcp_request_id": "...",
|
||||
"trace_id": "...",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
Correlation fields (`decision_id`, `policy_decision_id`, `entitlement_version`,
|
||||
`policy_version`, `mcp_request_id`, `trace_id`) are first-class, never hidden inside
|
||||
`payload` — a consumer can join across events and between events and OTel spans without
|
||||
parsing `payload`.
|
||||
|
||||
## Taxonomy (`category`)
|
||||
|
||||
Adopted from the Agent Platform OCI reference (`docs/adr/ADR-022-referencia-hoshikawa-agent-platform-oci.md`)
|
||||
rather than a taxonomy of its own:
|
||||
|
||||
| Category | Meaning | `event_type` examples |
|
||||
|---|---|---|
|
||||
| `ic` | Customer Interaction / business journey — the call progressed normally | `client_authenticated`, `tool_call_allowed`, `agent_turn_completed`, `nba_recalculated` |
|
||||
| `noc` | Operational/error — an availability failure, not a policy decision | `pdp_unavailable`, `backend_credential_unavailable` |
|
||||
| `grl` | Guardrail/governance — an entitlement/policy boundary was applied or tested | `tool_call_denied`, `tools_list_filtered`, `tool_call_require_approval`, `approval_granted`, `bypass_attempt_detected`, `journey_ended_after_denial` |
|
||||
|
||||
Each `event_type` fixes its own category in the factory that creates it (`audit/events.py`) —
|
||||
never a call-site decision.
|
||||
|
||||
## Sanitization (never optional)
|
||||
|
||||
Any `payload` key, at any nesting level, whose (lowercased) name contains `token`, `secret`,
|
||||
`password`, `authorization`, or `credential` has its value replaced with `sha256:<hex>` — a
|
||||
real secret value never reaches a sink. This runs in
|
||||
`EventEnvelope.model_post_init`, so it applies to every construction path, including
|
||||
deserialization from an existing sink.
|
||||
|
||||
## Available `event_type` values
|
||||
|
||||
| `event_type` | Category | Emitted when |
|
||||
|---|---|---|
|
||||
| `client_authenticated` | ic | A `tools/list` with a valid token resolved a known client. |
|
||||
| `tools_list_filtered` | grl | A `tools/list` returned (includes `allowed`/`total` counts). |
|
||||
| `tool_call_allowed` | ic | A `tools/call` was executed successfully. |
|
||||
| `tool_call_denied` | grl | Any `tools/call` denial (with `reason_code`). |
|
||||
| `tool_call_require_approval` | grl | An attempt generated/encountered a pending `ApprovalRequest`. |
|
||||
| `approval_granted` | grl | An approver granted an approval (`ApprovalService.grant`). |
|
||||
| `pdp_unavailable` | noc | OPA did not respond to a `tools/call` query. |
|
||||
| `backend_credential_unavailable` | noc | The outbound credential for a backend could not be obtained. |
|
||||
| `bypass_attempt_detected` | grl | A request reached a domain server without the Gateway's outbound credential (EP-05-T07). |
|
||||
| `nba_recalculated` | ic | The Orchestrator recalculated the NBA after a DENY from the Gateway (never re-executes the denied action). |
|
||||
| `agent_turn_completed` | ic | An example agent's turn ended — includes `tools_attempted`/`tools_denied` (a tool denied here is the guardrail working, not a bug). |
|
||||
| `journey_ended_after_denial` | grl | No offering survived a DENY — the journey ends, with no direct attempt against the Fabric. |
|
||||
|
||||
## Building a consumer
|
||||
|
||||
A SIEM/dashboard should: read the JSONL (or the equivalent production sink), index by
|
||||
`decision_id`/`mcp_request_id`/`trace_id` for end-to-end reconstruction, and treat any
|
||||
`category: "grl"` event as a guardrail signal (not necessarily an incident — a correct denial
|
||||
is the system working), and `category: "noc"` as an availability signal.
|
||||
88
RI/docs/interfaces/audit-events.md
Normal file
88
RI/docs/interfaces/audit-events.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Interface: eventos de auditoria
|
||||
|
||||
Taxonomia e schema dos eventos de auditoria, para quem vai construir um consumidor (SIEM,
|
||||
dashboard). Implementação: [`src/emcp_bus/audit/models.py`](../../src/emcp_bus/audit/models.py),
|
||||
[`audit/events.py`](../../src/emcp_bus/audit/events.py),
|
||||
[`audit/sink.py`](../../src/emcp_bus/audit/sink.py),
|
||||
[`audit/correlation.py`](../../src/emcp_bus/audit/correlation.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#auditoria-correlação-e-observabilidade-ep-08).
|
||||
|
||||
## Sink
|
||||
|
||||
`JSONLFileAuditSink` grava um objeto JSON por linha, append-only, em `EMCP_AUDIT_LOG_PATH`
|
||||
(padrão `/tmp/emcp-audit.jsonl`). Um consumidor de produção substituiria isso por um sink
|
||||
durável (Postgres, fila) — ver `../Production-Recommendations.md`; o schema do `EventEnvelope`
|
||||
abaixo não mudaria.
|
||||
|
||||
## `EventEnvelope`
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "3f9a...",
|
||||
"event_type": "tool_call_denied",
|
||||
"category": "grl",
|
||||
"timestamp": "2026-09-14T12:00:00Z",
|
||||
"client_id": "sales-read-agent",
|
||||
"profile_name": "Sales.Read",
|
||||
"tool": "finance.payment.execute",
|
||||
"reason_code": "NOT_IN_ENTITLEMENT",
|
||||
"decision_id": "...",
|
||||
"policy_decision_id": "...",
|
||||
"entitlement_version": "...",
|
||||
"policy_version": "a1b2c3d4e5f6",
|
||||
"mcp_request_id": "...",
|
||||
"trace_id": "...",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
Campos de correlação (`decision_id`, `policy_decision_id`, `entitlement_version`,
|
||||
`policy_version`, `mcp_request_id`, `trace_id`) são de primeira classe, nunca escondidos dentro
|
||||
de `payload` — um consumidor pode fazer join entre eventos e entre eventos e spans OTel sem
|
||||
parsear `payload`.
|
||||
|
||||
## Taxonomia (`category`)
|
||||
|
||||
Adotada da referência Agent Platform OCI (`docs/adr/ADR-022-referencia-hoshikawa-agent-platform-oci.md`)
|
||||
em vez de uma taxonomia própria:
|
||||
|
||||
| Categoria | Significado | Exemplos de `event_type` |
|
||||
|---|---|---|
|
||||
| `ic` | Interação de Cliente / negócio-jornada — a chamada progrediu normalmente | `client_authenticated`, `tool_call_allowed`, `agent_turn_completed`, `nba_recalculated` |
|
||||
| `noc` | Operacional/erro — falha de disponibilidade, não uma decisão de política | `pdp_unavailable`, `backend_credential_unavailable` |
|
||||
| `grl` | Guardrail/governança — uma fronteira de entitlement/política foi aplicada ou testada | `tool_call_denied`, `tools_list_filtered`, `tool_call_require_approval`, `approval_granted`, `bypass_attempt_detected`, `journey_ended_after_denial` |
|
||||
|
||||
Cada `event_type` fixa sua própria categoria na fábrica que o cria (`audit/events.py`) — nunca é
|
||||
uma decisão do call site.
|
||||
|
||||
## Sanitização (nunca opcional)
|
||||
|
||||
Qualquer chave de `payload`, em qualquer nível de aninhamento, cujo nome (lowercased) contenha
|
||||
`token`, `secret`, `password`, `authorization` ou `credential` tem seu valor substituído por
|
||||
`sha256:<hex>` — nunca um valor real de segredo chega a um sink. Isso roda em
|
||||
`EventEnvelope.model_post_init`, então vale para toda via de construção, inclusive
|
||||
desserialização a partir de um sink existente.
|
||||
|
||||
## `event_type` disponíveis
|
||||
|
||||
| `event_type` | Categoria | Emitido quando |
|
||||
|---|---|---|
|
||||
| `client_authenticated` | ic | Um `tools/list` com token válido resolveu um client conhecido. |
|
||||
| `tools_list_filtered` | grl | Um `tools/list` retornou (inclui contagem `allowed`/`total`). |
|
||||
| `tool_call_allowed` | ic | Um `tools/call` foi executado com sucesso. |
|
||||
| `tool_call_denied` | grl | Qualquer negação de `tools/call` (com `reason_code`). |
|
||||
| `tool_call_require_approval` | grl | Uma tentativa gerou/encontrou um `ApprovalRequest` pendente. |
|
||||
| `approval_granted` | grl | Um approver concedeu uma aprovação (`ApprovalService.grant`). |
|
||||
| `pdp_unavailable` | noc | O OPA não respondeu a uma consulta de `tools/call`. |
|
||||
| `backend_credential_unavailable` | noc | A credencial de saída para um backend não pôde ser obtida. |
|
||||
| `bypass_attempt_detected` | grl | Uma requisição alcançou um domain server sem a credencial de saída do Gateway (EP-05-T07). |
|
||||
| `nba_recalculated` | ic | O Orchestrator recalculou a NBA após um DENY do Gateway (nunca reexecuta a ação negada). |
|
||||
| `agent_turn_completed` | ic | Um turno do agente de exemplo terminou — inclui `tools_attempted`/`tools_denied` (uma tool negada aqui é o guardrail funcionando, não um bug). |
|
||||
| `journey_ended_after_denial` | grl | Nenhuma offering sobreviveu a um DENY — a jornada termina, sem tentativa direta ao Fabric. |
|
||||
|
||||
## Construindo um consumidor
|
||||
|
||||
Um SIEM/dashboard deveria: ler o JSONL (ou o sink de produção equivalente), indexar por
|
||||
`decision_id`/`mcp_request_id`/`trace_id` para reconstrução ponta a ponta, e tratar qualquer
|
||||
evento `category: "grl"` como um sinal de guardrail (não necessariamente um incidente — negação
|
||||
correta é o sistema funcionando), e `category: "noc"` como sinal de disponibilidade.
|
||||
94
RI/docs/interfaces/downstream-identity.en-us.md
Normal file
94
RI/docs/interfaces/downstream-identity.en-us.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Interface: downstream / outbound identity
|
||||
|
||||
Outbound identity contract, for whoever is going to integrate a new backend/domain server.
|
||||
Implementation: [`src/emcp_bus/downstream/models.py`](../../src/emcp_bus/downstream/models.py),
|
||||
[`downstream/identity.py`](../../src/emcp_bus/downstream/identity.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#downstream--outbound-identity-ep-07).
|
||||
|
||||
Principle: `inbound token (MCP Client → Gateway) ≠ outbound token (Fabric → Backend)`. The Gateway
|
||||
authenticates the *caller*; this contract resolves a separate question — which credential the
|
||||
Fabric presents *to the backend* — and never resolves it by forwarding the inbound token
|
||||
unmodified.
|
||||
|
||||
## Registering a backend (`BackendConfig`)
|
||||
|
||||
One YAML file per backend in `config/backends/*.yaml`:
|
||||
|
||||
```yaml
|
||||
# service_account (default mode of the seed backends)
|
||||
backend: sales-domain
|
||||
url: http://sales-domain:8100/mcp
|
||||
audience: sales-domain-service # never the same as the Gateway's inbound OIDC_AUDIENCE
|
||||
credential_mode: service_account
|
||||
service_account_token_env_var: SALES_DOMAIN_SERVICE_TOKEN # env var name, never a literal value
|
||||
```
|
||||
|
||||
```yaml
|
||||
# token_exchange (available alternative, verified against real Keycloak)
|
||||
backend: finance-domain
|
||||
url: http://finance-domain:8101/mcp
|
||||
audience: finance-domain-service
|
||||
credential_mode: token_exchange
|
||||
token_exchange_endpoint: http://keycloak:8080/realms/emcp/protocol/openid-connect/token
|
||||
token_exchange_scope: aud-finance-domain-service
|
||||
```
|
||||
|
||||
## `service_account` mode
|
||||
|
||||
Static credential, read from an environment variable (name, never value, declared in
|
||||
`service_account_token_env_var`) at the time of each call. Absence of the variable at runtime →
|
||||
`BackendCredentialUnavailableError`.
|
||||
|
||||
## `token_exchange` mode (RFC 8693, Keycloak Standard Token Exchange)
|
||||
|
||||
Two HTTP round trips per new exchange (`TokenExchangeClient.exchange`): (1) `client_credentials`
|
||||
as the Gateway's own exchange client, producing a `subject_token`; (2) exchanging that
|
||||
`subject_token` for a token with the target backend's `audience`. Cached per backend until
|
||||
shortly before the exchange's own `expires_in` — a cache hit costs zero round trips.
|
||||
|
||||
**Required IdP configuration (empirically verified against real Keycloak 26.7.3):**
|
||||
|
||||
- The Gateway's exchange client (`GATEWAY_TOKEN_EXCHANGE_CLIENT_ID`/`_SECRET`) needs
|
||||
`attributes.standard.token.exchange.enabled = "true"` and `serviceAccountsEnabled`.
|
||||
- `audience` must be the id of a client **actually registered** in the realm — a free-form string
|
||||
fails with `"Audience not found"`.
|
||||
- Requesting `audience` without also requesting, via `scope`, a client scope that actually adds
|
||||
that audience fails with `"Requested audience not available"` — the `audience` parameter only
|
||||
**restricts**, never **adds**. That is why `token_exchange_scope` is mandatory in this mode.
|
||||
- That client scope must carry an `oidc-audience-mapper` pointing at the backend's `audience`,
|
||||
and be a default scope (or requested optional scope) of the Gateway's exchange client.
|
||||
|
||||
See `deploy/keycloak/realm-export.json` for a real, already-configured example (`gateway-token-exchange`
|
||||
+ client scopes `aud-sales-domain-service`/`aud-finance-domain-service`).
|
||||
|
||||
## Outbound contract (`BackendCredential`)
|
||||
|
||||
```python
|
||||
BackendCredential(
|
||||
backend="finance-domain",
|
||||
url="http://finance-domain:8101/mcp",
|
||||
token="...",
|
||||
audience="finance-domain-service",
|
||||
)
|
||||
```
|
||||
|
||||
## How to integrate a new backend
|
||||
|
||||
1. Register the backend with the IdP if using `token_exchange` (client scope +
|
||||
`oidc-audience-mapper` for the chosen `audience`) — or simply define the static token's env
|
||||
var if using `service_account`.
|
||||
2. Create `config/backends/<backend>.yaml` (`BackendConfig`).
|
||||
3. Reference `backend.service` == that same id in every `CapabilityManifest` that routes to
|
||||
it (see [`registry-capability-manifest.md`](registry-capability-manifest.md)).
|
||||
4. If the backend is MCP-native (`backend.type: mcp`, the only one with live dispatch in this
|
||||
RI), implement the `BackendCredentialGate` on the backend side (see
|
||||
`services/example_mcp_servers/sales_domain/server.py` as a reference) — a request without the
|
||||
Gateway's outbound credential should never reach a tool handler.
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception | Handling by the caller (Gateway) |
|
||||
|---|---|---|
|
||||
| `backend` without a registered `BackendConfig` | `UnknownBackendError` | `UnroutableCapabilityError` in the Fabric → `UNROUTABLE_CAPABILITY`. |
|
||||
| `service_account` env var missing at runtime | `BackendCredentialUnavailableError` | `backend_credential_unavailable` (NOC event) → call denied. |
|
||||
| RFC 8693 exchange rejected by the IdP | `TokenExchangeError` → re-raised as `BackendCredentialUnavailableError` | Same as above. |
|
||||
93
RI/docs/interfaces/downstream-identity.md
Normal file
93
RI/docs/interfaces/downstream-identity.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Interface: downstream / outbound identity
|
||||
|
||||
Contrato de identidade de saída, para quem vai integrar um novo backend/domain server.
|
||||
Implementação: [`src/emcp_bus/downstream/models.py`](../../src/emcp_bus/downstream/models.py),
|
||||
[`downstream/identity.py`](../../src/emcp_bus/downstream/identity.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#downstream--outbound-identity-ep-07).
|
||||
|
||||
Princípio: `inbound token (MCP Client → Gateway) ≠ outbound token (Fabric → Backend)`. O Gateway
|
||||
autentica o *caller*; este contrato resolve uma pergunta separada — qual credencial o Fabric
|
||||
apresenta *ao backend* — e nunca a resolve repassando o token inbound sem modificação.
|
||||
|
||||
## Registro de um backend (`BackendConfig`)
|
||||
|
||||
Um arquivo YAML por backend em `config/backends/*.yaml`:
|
||||
|
||||
```yaml
|
||||
# service_account (modo padrão dos backends seed)
|
||||
backend: sales-domain
|
||||
url: http://sales-domain:8100/mcp
|
||||
audience: sales-domain-service # nunca igual ao OIDC_AUDIENCE inbound do Gateway
|
||||
credential_mode: service_account
|
||||
service_account_token_env_var: SALES_DOMAIN_SERVICE_TOKEN # nome da env var, nunca um valor literal
|
||||
```
|
||||
|
||||
```yaml
|
||||
# token_exchange (alternativa disponível e verificada contra Keycloak real)
|
||||
backend: finance-domain
|
||||
url: http://finance-domain:8101/mcp
|
||||
audience: finance-domain-service
|
||||
credential_mode: token_exchange
|
||||
token_exchange_endpoint: http://keycloak:8080/realms/emcp/protocol/openid-connect/token
|
||||
token_exchange_scope: aud-finance-domain-service
|
||||
```
|
||||
|
||||
## Modo `service_account`
|
||||
|
||||
Credencial estática, lida de uma variável de ambiente (nome, nunca valor, declarado em
|
||||
`service_account_token_env_var`) no momento de cada chamada. Ausência da variável em runtime →
|
||||
`BackendCredentialUnavailableError`.
|
||||
|
||||
## Modo `token_exchange` (RFC 8693, Keycloak Standard Token Exchange)
|
||||
|
||||
Dois round-trips HTTP por troca nova (`TokenExchangeClient.exchange`): (1) `client_credentials`
|
||||
como o próprio client de exchange do Gateway, produzindo um `subject_token`; (2) troca desse
|
||||
`subject_token` por um token com a `audience` do backend alvo. Cacheado por backend até pouco
|
||||
antes do próprio `expires_in` da troca — um cache hit custa zero round-trips.
|
||||
|
||||
**Configuração exigida no IdP (verificada empiricamente contra Keycloak 26.7.3 real):**
|
||||
|
||||
- O client de exchange do Gateway (`GATEWAY_TOKEN_EXCHANGE_CLIENT_ID`/`_SECRET`) precisa ter
|
||||
`attributes.standard.token.exchange.enabled = "true"` e `serviceAccountsEnabled`.
|
||||
- `audience` precisa ser o id de um client **realmente registrado** no realm — uma string livre
|
||||
falha com `"Audience not found"`.
|
||||
- Pedir `audience` sem também pedir, via `scope`, um client scope que efetivamente adiciona essa
|
||||
audience falha com `"Requested audience not available"` — o parâmetro `audience` só
|
||||
**restringe**, nunca **adiciona**. Por isso `token_exchange_scope` é obrigatório neste modo.
|
||||
- Esse client scope precisa carregar um `oidc-audience-mapper` apontando para a `audience` do
|
||||
backend, e ser um scope padrão (ou opcional solicitado) do client de exchange do Gateway.
|
||||
|
||||
Ver `deploy/keycloak/realm-export.json` para um exemplo real já configurado (`gateway-token-exchange`
|
||||
+ client scopes `aud-sales-domain-service`/`aud-finance-domain-service`).
|
||||
|
||||
## Contrato de saída (`BackendCredential`)
|
||||
|
||||
```python
|
||||
BackendCredential(
|
||||
backend="finance-domain",
|
||||
url="http://finance-domain:8101/mcp",
|
||||
token="...",
|
||||
audience="finance-domain-service",
|
||||
)
|
||||
```
|
||||
|
||||
## Como integrar um novo backend
|
||||
|
||||
1. Registrar o backend no IdP se usar `token_exchange` (client scope + `oidc-audience-mapper`
|
||||
para a `audience` escolhida) — ou apenas definir a env var do token estático se usar
|
||||
`service_account`.
|
||||
2. Criar `config/backends/<backend>.yaml` (`BackendConfig`).
|
||||
3. Referenciar `backend.service` == esse mesmo id em cada `CapabilityManifest` que rotear para
|
||||
ele (ver [`registry-capability-manifest.md`](registry-capability-manifest.md)).
|
||||
4. Se o backend for MCP-nativo (`backend.type: mcp`, o único com dispatch ao vivo nesta RI),
|
||||
implementar o `BackendCredentialGate` do lado do backend (ver
|
||||
`services/example_mcp_servers/sales_domain/server.py` como referência) — uma requisição sem a
|
||||
credencial de saída do Gateway nunca deve alcançar um handler de tool.
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção | Tratamento pelo caller (Gateway) |
|
||||
|---|---|---|
|
||||
| `backend` sem `BackendConfig` registrado | `UnknownBackendError` | `UnroutableCapabilityError` no Fabric → `UNROUTABLE_CAPABILITY`. |
|
||||
| Env var de `service_account` ausente em runtime | `BackendCredentialUnavailableError` | `backend_credential_unavailable` (evento NOC) → chamada negada. |
|
||||
| Troca RFC 8693 rejeitada pelo IdP | `TokenExchangeError` → relançada como `BackendCredentialUnavailableError` | Idem acima. |
|
||||
65
RI/docs/interfaces/identity-oidc.en-us.md
Normal file
65
RI/docs/interfaces/identity-oidc.en-us.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Interface: MCP Client identity and registration (OIDC)
|
||||
|
||||
Contract for registering an MCP Client and the OIDC token it presents to the Gateway.
|
||||
Implementation: [`src/emcp_bus/identity/models.py`](../../src/emcp_bus/identity/models.py),
|
||||
[`identity/authn.py`](../../src/emcp_bus/identity/authn.py),
|
||||
[`identity/shared_client_lint.py`](../../src/emcp_bus/identity/shared_client_lint.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#identidade-do-mcp-client-ep-01).
|
||||
|
||||
## Registering an MCP Client
|
||||
|
||||
One YAML file per client in `config/clients/*.yaml`, validated against `ClientRegistration`:
|
||||
|
||||
```yaml
|
||||
client_id: finance-payments-agent # exactly the token's azp/client_id claim
|
||||
profile: Finance.Payments # name of a ClientProfile under config/clients/profiles/
|
||||
environment: production # "dev" | "ci" | "production"
|
||||
description: "Finance agent authorized to create/execute payments (R3, approval above threshold)"
|
||||
```
|
||||
|
||||
(real example: `config/clients/finance-payments-agent.yaml`)
|
||||
|
||||
`client_id` is the sole link between an authenticated identity and a `ClientProfile` — two
|
||||
distinct `client_id`s never share a `profile` unless that is an explicit and auditable decision
|
||||
(the shared-client rule, `identity/shared_client_lint.py`, wired into
|
||||
`make validate-schemas`).
|
||||
|
||||
## Registering the client with the IdP
|
||||
|
||||
The client must exist in the IdP realm with the `client_credentials` grant enabled and an
|
||||
audience mapper pointing at the Gateway's `OIDC_AUDIENCE` (`emcp-gateway` by default). See
|
||||
`deploy/keycloak/realm-export.json` for the 3 example clients already configured this way
|
||||
(`sales-read-agent`, `sales-write-agent`, `finance-payments-agent`).
|
||||
|
||||
## Access token contract
|
||||
|
||||
Any OIDC-compliant IdP works (`TokenValidator` is provider-agnostic). Claims required and
|
||||
validated by `TokenValidator.validate`:
|
||||
|
||||
| Claim | Use |
|
||||
|---|---|
|
||||
| `exp` | Expiration — required, validated with `leeway_seconds=5`. |
|
||||
| `iss` | Must match `OIDC_ISSUER` exactly. |
|
||||
| `aud` | Must contain `OIDC_AUDIENCE`. |
|
||||
| `azp` (preferred) or `client_id` | Sole source of client identity — never `clientInfo` from the MCP request nor any body field. |
|
||||
|
||||
Signature verified via JWKS (`OIDC_JWKS_URL`), `RS256` algorithm. Failure of any check raises
|
||||
`AuthenticationError`, treated by the Gateway as `UNAUTHENTICATED` (fail-closed).
|
||||
|
||||
## How a new client registers, end to end
|
||||
|
||||
1. Create/update the client in the IdP (`client_credentials` grant, audience mapper).
|
||||
2. Create `config/clients/<client-id>.yaml` (`ClientRegistration`).
|
||||
3. Point `profile` at an existing `ClientProfile`, or create a new one under
|
||||
`config/clients/profiles/` (see [`registry-capability-manifest.md`](registry-capability-manifest.md)
|
||||
for how a capability references a profile via `entitlements`).
|
||||
4. `make validate-schemas`.
|
||||
5. Restart the Gateway (client registrations are loaded at boot, no hot-reload).
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Behavior |
|
||||
|---|---|
|
||||
| Missing/malformed/expired token or invalid signature | `AuthenticationError` → Gateway treats it as `UNAUTHENTICATED`. |
|
||||
| Incorrect `aud`/`iss` | `AuthenticationError` → `UNAUTHENTICATED` (README.md §45 Test 8). |
|
||||
| Valid token, but `client_id` without a matching `ClientRegistration` | `UnknownClientError` → Gateway responds `UNKNOWN_CLIENT` on `tools/call`, empty list on `tools/list`. |
|
||||
65
RI/docs/interfaces/identity-oidc.md
Normal file
65
RI/docs/interfaces/identity-oidc.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Interface: identidade e registro de MCP Client (OIDC)
|
||||
|
||||
Contrato de registro de um MCP Client e do token OIDC que ele apresenta ao Gateway.
|
||||
Implementação: [`src/emcp_bus/identity/models.py`](../../src/emcp_bus/identity/models.py),
|
||||
[`identity/authn.py`](../../src/emcp_bus/identity/authn.py),
|
||||
[`identity/shared_client_lint.py`](../../src/emcp_bus/identity/shared_client_lint.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#identidade-do-mcp-client-ep-01).
|
||||
|
||||
## Registro de um MCP Client
|
||||
|
||||
Um arquivo YAML por client em `config/clients/*.yaml`, validado contra `ClientRegistration`:
|
||||
|
||||
```yaml
|
||||
client_id: finance-payments-agent # exatamente a claim azp/client_id do token
|
||||
profile: Finance.Payments # nome de um ClientProfile em config/clients/profiles/
|
||||
environment: production # "dev" | "ci" | "production"
|
||||
description: "Finance agent authorized to create/execute payments (R3, approval above threshold)"
|
||||
```
|
||||
|
||||
(exemplo real: `config/clients/finance-payments-agent.yaml`)
|
||||
|
||||
`client_id` é o único vínculo entre uma identidade autenticada e um `ClientProfile` — dois
|
||||
`client_id`s distintos nunca compartilham `profile` a menos que isso seja uma decisão explícita
|
||||
e auditável (regra de shared client, `identity/shared_client_lint.py`, ligada a
|
||||
`make validate-schemas`).
|
||||
|
||||
## Registro do client no IdP
|
||||
|
||||
O client precisa existir no realm do IdP com o grant `client_credentials` habilitado e um
|
||||
mapper de audience apontando para o `OIDC_AUDIENCE` do Gateway (`emcp-gateway` por padrão). Ver
|
||||
`deploy/keycloak/realm-export.json` para os 3 clients de exemplo já configurados dessa forma
|
||||
(`sales-read-agent`, `sales-write-agent`, `finance-payments-agent`).
|
||||
|
||||
## Contrato do token de acesso
|
||||
|
||||
Qualquer IdP compatível com OIDC funciona (`TokenValidator` é agnóstico de fornecedor). Claims
|
||||
exigidas e validadas por `TokenValidator.validate`:
|
||||
|
||||
| Claim | Uso |
|
||||
|---|---|
|
||||
| `exp` | Expiração — obrigatória, validada com `leeway_seconds=5`. |
|
||||
| `iss` | Deve casar exatamente com `OIDC_ISSUER`. |
|
||||
| `aud` | Deve conter `OIDC_AUDIENCE`. |
|
||||
| `azp` (preferencial) ou `client_id` | Fonte única de identidade do client — nunca `clientInfo` do request MCP nem qualquer campo do corpo. |
|
||||
|
||||
Assinatura verificada via JWKS (`OIDC_JWKS_URL`), algoritmo `RS256`. Falha em qualquer checagem
|
||||
levanta `AuthenticationError`, tratada pelo Gateway como `UNAUTHENTICATED` (fail-closed).
|
||||
|
||||
## Como um novo client se registra, ponta a ponta
|
||||
|
||||
1. Criar/atualizar o client no IdP (grant `client_credentials`, mapper de audience).
|
||||
2. Criar `config/clients/<client-id>.yaml` (`ClientRegistration`).
|
||||
3. Apontar `profile` para um `ClientProfile` existente, ou criar um novo em
|
||||
`config/clients/profiles/` (ver [`registry-capability-manifest.md`](registry-capability-manifest.md)
|
||||
para como uma capability referencia um profile via `entitlements`).
|
||||
4. `make validate-schemas`.
|
||||
5. Reiniciar o Gateway (registros de client são carregados no boot, sem hot-reload).
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Comportamento |
|
||||
|---|---|
|
||||
| Token ausente/malformado/expirado/assinatura inválida | `AuthenticationError` → Gateway trata como `UNAUTHENTICATED`. |
|
||||
| `aud`/`iss` incorretos | `AuthenticationError` → `UNAUTHENTICATED` (README.md §45 Teste 8). |
|
||||
| Token válido, mas `client_id` sem `ClientRegistration` correspondente | `UnknownClientError` → Gateway responde `UNKNOWN_CLIENT` em `tools/call`, lista vazia em `tools/list`. |
|
||||
90
RI/docs/interfaces/mcp-gateway.en-us.md
Normal file
90
RI/docs/interfaces/mcp-gateway.en-us.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Interface: MCP Gateway
|
||||
|
||||
The Gateway's MCP surface — what any integrating MCP Client calls. Implementation:
|
||||
[`src/emcp_bus/gateway/server.py`](../../src/emcp_bus/gateway/server.py),
|
||||
[`gateway/canonical_request.py`](../../src/emcp_bus/gateway/canonical_request.py),
|
||||
[`gateway/cache.py`](../../src/emcp_bus/gateway/cache.py). See [`AS-BUILT.md`](../AS-BUILT.md#pep--mcp-gateway-ep-05)
|
||||
for the component's role in the architecture.
|
||||
|
||||
## Authentication
|
||||
|
||||
OAuth2/OIDC bearer token in the `Authorization` header. Validated against the JWKS of the configured
|
||||
IdP (`OIDC_JWKS_URL`/`OIDC_ISSUER`/`OIDC_AUDIENCE`) — RS256 signature, `exp`, `iss`, `aud`. The
|
||||
trusted identity is the token's `azp` claim (or, in its absence, `client_id`) — never a
|
||||
`clientInfo` field or any part of the request body.
|
||||
|
||||
## Two deployment modes
|
||||
|
||||
| Mode | Condition | Behavior |
|
||||
|---|---|---|
|
||||
| With OIDC configured | `OIDC_JWKS_URL` + `OIDC_ISSUER` set | The `mcp` SDK's auth middleware rejects any connection without a valid bearer token before the `initialize` handshake. |
|
||||
| Without OIDC configured | Env vars absent (default) | No auth middleware installed; every handler runs with `get_access_token() is None` and denies by default. |
|
||||
|
||||
Both modes are deny-by-default; neither is an "incomplete mode" waiting on the other — see
|
||||
`tests/e2e/test_governed_gateway.py`.
|
||||
|
||||
## `tools/list`
|
||||
|
||||
Without a valid token: returns `tools: []`. With a valid token: returns the intersection of the
|
||||
entitled backends' catalog (`CapabilityRouter.distinct_backends_for`) with the client's
|
||||
`MaximumEntitlement` (`EntitlementManager.resolve(...).allowed_tools`) — never any backend's full
|
||||
catalog. Each call re-evaluates entitlement from scratch; there is no server-side catalog cache
|
||||
between clients.
|
||||
|
||||
**Cache hint (SEP-2549):** the response carries `ttl_ms`/`cache_scope`, always
|
||||
`cache_scope="private"` — never `"public"`, since the result depends on the caller's authorization
|
||||
(`gateway/cache.py:tools_list_cache_hint`). **Verified limitation**: on the classic session
|
||||
transport this RI uses everywhere, the SDK drops `ttl_ms` during serialization (the field is only
|
||||
delivered on the wire by the protocol's 2026-07-28 *stateless per-request* mode) — the client
|
||||
always sees the default (`ttl_ms=0`), which is the most conservative fallback possible, never an
|
||||
unsafe one. The actual security property (never leaking a catalog between clients) does not
|
||||
depend on this — it comes from the Gateway recomputing entitlement on every call.
|
||||
|
||||
## `tools/call`
|
||||
|
||||
Reauthorized completely independently of `tools/list` — the prior list is never consulted to
|
||||
decide whether a call is allowed. Flow:
|
||||
|
||||
1. No valid token → deny (see error table below).
|
||||
2. Unknown client (`ClientRegistration` not found) → deny.
|
||||
3. PDP consulted (`PDPClient.evaluate`, see [`pdp-opa.md`](pdp-opa.md)) with `client_profile`,
|
||||
`tool`, `arguments`.
|
||||
4. `DENY` → deny with the PDP's `reason_code`.
|
||||
5. `REQUIRE_APPROVAL` → consults `ApprovalService.check_and_consume`; without a granted approval,
|
||||
creates an `ApprovalRequest` (see [`approval-workflow.md`](approval-workflow.md)) and denies.
|
||||
6. `ALLOW` → resolves the backend via `CapabilityRouter.route` (see
|
||||
[`registry-capability-manifest.md`](registry-capability-manifest.md)) and executes the call
|
||||
against the domain server, using the outbound credential (never the client's inbound token — see
|
||||
[`downstream-identity.md`](downstream-identity.md)).
|
||||
|
||||
### Denial format
|
||||
|
||||
Every denial is a structured `CallToolResult` (`is_error: true`), never a protocol error:
|
||||
|
||||
```json
|
||||
{"is_error": true, "content": [{"type": "text", "text": "Access denied: <REASON_CODE>"}]}
|
||||
```
|
||||
|
||||
| `REASON_CODE` | When |
|
||||
|---|---|
|
||||
| `UNAUTHENTICATED` | No valid token. |
|
||||
| `UNKNOWN_CLIENT` | Valid token, but `client_id` without a `ClientRegistration`. |
|
||||
| `PDP_UNAVAILABLE` | OPA unreachable — fail-closed, never an implicit ALLOW. |
|
||||
| `NOT_IN_ENTITLEMENT` | The tool is not in `ClientProfile.allowed_tools` (PDP decision). |
|
||||
| `TRANSACTION_POLICY_DENIED` | Entitlement OK, but the arguments violate `transaction.rego` (limit, region, classification). |
|
||||
| `REQUIRE_APPROVAL:<reason>:<approval_id>` | Above the `approval_threshold` — use `approval_id` to track/grant out of band. |
|
||||
| `UNROUTABLE_CAPABILITY` | No `ACTIVE` manifest for the tool, or the backend is not resolvable. |
|
||||
| `UNSUPPORTED_BACKEND_TYPE:<type>` | The manifest points to a `backend.type` with no live dispatch in this RI (e.g. `rest`, see `AS-BUILT.md`). |
|
||||
|
||||
## Header/body consistency (EP-05-T08)
|
||||
|
||||
If the request carries the `Mcp-Method`/`Mcp-Name` headers (SEP-2243), they must agree with the
|
||||
`method`/resource name in the JSON-RPC body, or the request is rejected with HTTP 400
|
||||
(`{"error": "header_body_mismatch", "detail": "..."}`) before reaching authentication. Missing
|
||||
headers are not an error — it's a consistency check, not a mandate to adopt the SEP.
|
||||
|
||||
## Correlation
|
||||
|
||||
Every decision carries, and propagates as OTel span attributes and in every audit event:
|
||||
`decision_id`, `policy_decision_id`, `entitlement_version`, `policy_version`, `mcp_request_id`.
|
||||
See [`audit-events.md`](audit-events.md).
|
||||
90
RI/docs/interfaces/mcp-gateway.md
Normal file
90
RI/docs/interfaces/mcp-gateway.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Interface: MCP Gateway
|
||||
|
||||
A superfície MCP do Gateway — o que qualquer MCP Client integrador chama. Implementação:
|
||||
[`src/emcp_bus/gateway/server.py`](../../src/emcp_bus/gateway/server.py),
|
||||
[`gateway/canonical_request.py`](../../src/emcp_bus/gateway/canonical_request.py),
|
||||
[`gateway/cache.py`](../../src/emcp_bus/gateway/cache.py). Ver [`AS-BUILT.md`](../AS-BUILT.md#pep--mcp-gateway-ep-05)
|
||||
para o papel do componente na arquitetura.
|
||||
|
||||
## Autenticação
|
||||
|
||||
Bearer token OAuth2/OIDC no header `Authorization`. Validado contra o JWKS do IdP configurado
|
||||
(`OIDC_JWKS_URL`/`OIDC_ISSUER`/`OIDC_AUDIENCE`) — assinatura RS256, `exp`, `iss`, `aud`. A
|
||||
identidade confiável é a claim `azp` (ou, na ausência dela, `client_id`) do token — nunca um
|
||||
campo de `clientInfo` ou de qualquer parte do corpo da requisição.
|
||||
|
||||
## Dois modos de deployment
|
||||
|
||||
| Modo | Condição | Comportamento |
|
||||
|---|---|---|
|
||||
| Com OIDC configurado | `OIDC_JWKS_URL` + `OIDC_ISSUER` definidos | O middleware de auth do SDK `mcp` rejeita qualquer conexão sem bearer token válido antes do handshake `initialize`. |
|
||||
| Sem OIDC configurado | Env vars ausentes (padrão) | Nenhum middleware de auth instalado; todo handler roda com `get_access_token() is None` e nega por padrão. |
|
||||
|
||||
Os dois modos são deny-by-default; nenhum é um "modo incompleto" esperando o outro — ver
|
||||
`tests/e2e/test_governed_gateway.py`.
|
||||
|
||||
## `tools/list`
|
||||
|
||||
Sem token válido: retorna `tools: []`. Com token válido: retorna a interseção do catálogo dos
|
||||
backends entitled (`CapabilityRouter.distinct_backends_for`) com o `MaximumEntitlement` do
|
||||
client (`EntitlementManager.resolve(...).allowed_tools`) — nunca o catálogo completo de nenhum
|
||||
backend. Cada chamada reavalia o entitlement do zero; não há cache de catálogo no lado do
|
||||
servidor entre clients.
|
||||
|
||||
**Cache hint (SEP-2549):** a resposta carrega `ttl_ms`/`cache_scope`, sempre
|
||||
`cache_scope="private"` — nunca `"public"`, já que o resultado depende da autorização do
|
||||
caller (`gateway/cache.py:tools_list_cache_hint`). **Limitação verificada**: no transporte de
|
||||
sessão clássica que esta RI usa em toda parte, o SDK descarta `ttl_ms` na serialização (o campo
|
||||
só é entregue no wire pelo modo *stateless per-request* do protocolo 2026-07-28) — o cliente
|
||||
sempre vê o default (`ttl_ms=0`), que é o fallback mais conservador possível, nunca inseguro. A
|
||||
propriedade de segurança real (nunca vazar catálogo entre clientes) não depende disso — vem do
|
||||
Gateway recalcular o entitlement em toda chamada.
|
||||
|
||||
## `tools/call`
|
||||
|
||||
Reautorizado de forma completamente independente de `tools/list` — a lista anterior nunca é
|
||||
consultada para decidir se uma chamada é permitida. Fluxo:
|
||||
|
||||
1. Sem token válido → nega (ver tabela de erros abaixo).
|
||||
2. Client desconhecido (`ClientRegistration` não encontrado) → nega.
|
||||
3. PDP consultado (`PDPClient.evaluate`, ver [`pdp-opa.md`](pdp-opa.md)) com `client_profile`,
|
||||
`tool`, `arguments`.
|
||||
4. `DENY` → nega com o `reason_code` do PDP.
|
||||
5. `REQUIRE_APPROVAL` → consulta `ApprovalService.check_and_consume`; sem aprovação concedida,
|
||||
cria um `ApprovalRequest` (ver [`approval-workflow.md`](approval-workflow.md)) e nega.
|
||||
6. `ALLOW` → resolve o backend via `CapabilityRouter.route` (ver
|
||||
[`registry-capability-manifest.md`](registry-capability-manifest.md)) e executa a chamada
|
||||
contra o domain server, usando a credencial de saída (nunca o token inbound do client — ver
|
||||
[`downstream-identity.md`](downstream-identity.md)).
|
||||
|
||||
### Formato de negação
|
||||
|
||||
Toda negação é um `CallToolResult` estruturado (`is_error: true`), nunca um erro de protocolo:
|
||||
|
||||
```json
|
||||
{"is_error": true, "content": [{"type": "text", "text": "Access denied: <REASON_CODE>"}]}
|
||||
```
|
||||
|
||||
| `REASON_CODE` | Quando |
|
||||
|---|---|
|
||||
| `UNAUTHENTICATED` | Sem token válido. |
|
||||
| `UNKNOWN_CLIENT` | Token válido, mas `client_id` sem `ClientRegistration`. |
|
||||
| `PDP_UNAVAILABLE` | OPA inalcançável — fail-closed, nunca ALLOW implícito. |
|
||||
| `NOT_IN_ENTITLEMENT` | A tool não está em `ClientProfile.allowed_tools` (decisão do PDP). |
|
||||
| `TRANSACTION_POLICY_DENIED` | Entitlement OK, mas os argumentos violam `transaction.rego` (limite, região, classificação). |
|
||||
| `REQUIRE_APPROVAL:<motivo>:<approval_id>` | Acima do `approval_threshold` — usa `approval_id` para acompanhar/conceder fora de banda. |
|
||||
| `UNROUTABLE_CAPABILITY` | Nenhum manifesto `ACTIVE` para a tool, ou backend não resolvível. |
|
||||
| `UNSUPPORTED_BACKEND_TYPE:<tipo>` | O manifesto aponta para um `backend.type` sem dispatch ao vivo nesta RI (ex.: `rest`, ver `AS-BUILT.md`). |
|
||||
|
||||
## Consistência header/body (EP-05-T08)
|
||||
|
||||
Se a requisição carregar os headers `Mcp-Method`/`Mcp-Name` (SEP-2243), eles devem concordar com
|
||||
o `method`/nome do recurso no corpo JSON-RPC, ou a requisição é rejeitada com HTTP 400
|
||||
(`{"error": "header_body_mismatch", "detail": "..."}`) antes de alcançar autenticação. Headers
|
||||
ausentes não são um erro — é uma checagem de consistência, não um mandato de adoção do SEP.
|
||||
|
||||
## Correlação
|
||||
|
||||
Toda decisão carrega, e propaga como atributos de span OTel e em todo evento de auditoria:
|
||||
`decision_id`, `policy_decision_id`, `entitlement_version`, `policy_version`, `mcp_request_id`.
|
||||
Ver [`audit-events.md`](audit-events.md).
|
||||
79
RI/docs/interfaces/pdp-opa.en-us.md
Normal file
79
RI/docs/interfaces/pdp-opa.en-us.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Interface: PDP (OPA)
|
||||
|
||||
Data/query contract for the Policy Decision Point. Implementation:
|
||||
[`src/emcp_bus/pdp/client.py`](../../src/emcp_bus/pdp/client.py),
|
||||
[`pdp/models.py`](../../src/emcp_bus/pdp/models.py),
|
||||
[`config/policies/entitlement.rego`](../../config/policies/entitlement.rego),
|
||||
[`config/policies/transaction.rego`](../../config/policies/transaction.rego). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#pdp--policy-engine-ep-03).
|
||||
|
||||
## Two-step model
|
||||
|
||||
`PDPClient.evaluate(client_profile, tool, arguments, customer_classification)` queries, in
|
||||
order, and never in parallel:
|
||||
|
||||
1. **`emcp.entitlement`** (`entitlement.rego`) — `allow` if `tool` is in
|
||||
`data.emcp.client_profiles[client_profile].allowed_tools`. `DENY` here is final
|
||||
(`reason_code=NOT_IN_ENTITLEMENT`) — no argument is ever evaluated.
|
||||
2. **`emcp.transaction`** (`transaction.rego`) — only evaluated if step 1 allowed. Restricts by
|
||||
argument/resource; can never add a tool absent from the entitlement. Returns `allow` and
|
||||
`require_approval` independently.
|
||||
|
||||
## Versioned data in OPA
|
||||
|
||||
`EntitlementManager.sync_profiles_to_pdp()` pushes each validated `ClientProfile` to
|
||||
`PUT /v1/data/emcp/client_profiles/<profile_name>` at boot — the Rego never reads YAML directly.
|
||||
|
||||
## Evaluation request
|
||||
|
||||
```
|
||||
POST /v1/data/emcp/entitlement/allow
|
||||
{"input": {"client_profile": "Sales.Write", "tool": "finance.payment.execute"}}
|
||||
```
|
||||
|
||||
```
|
||||
POST /v1/data/emcp/transaction
|
||||
{"input": {
|
||||
"client_profile": "Finance.Payments",
|
||||
"tool": "finance.payment.execute",
|
||||
"arguments": {"amount": 15000, "region": "BR-SP"},
|
||||
"customer_classification": "standard"
|
||||
}}
|
||||
```
|
||||
|
||||
## Combined response (`PDPDecision`)
|
||||
|
||||
```json
|
||||
{"outcome": "ALLOW" | "DENY" | "REQUIRE_APPROVAL", "policy_version": "a1b2c3d4e5f6", "reason_code": "..."}
|
||||
```
|
||||
|
||||
Never a lone boolean — `policy_version` and `reason_code` are mandatory in every response.
|
||||
|
||||
| `reason_code` | `outcome` | When |
|
||||
|---|---|---|
|
||||
| `NOT_IN_ENTITLEMENT` | `DENY` | Step 1 denied. |
|
||||
| `TRANSACTION_POLICY_DENIED` | `DENY` | Step 1 allowed, step 2 denied (limit, region, classification). |
|
||||
| `APPROVAL_THRESHOLD_EXCEEDED` | `REQUIRE_APPROVAL` | `allow=true` and `amount > restrictions.approval_threshold`. |
|
||||
| `ALLOWED` | `ALLOW` | Both steps allowed, without exceeding the threshold. |
|
||||
|
||||
## `policy_version`
|
||||
|
||||
A SHA-256 hash (12 characters) of the content of the `*.rego` files (except `*_test.rego`) in
|
||||
`config/policies/`, computed once at Gateway boot (`compute_policy_version`). Propagated in
|
||||
every decision and audit event — see [`audit-events.md`](audit-events.md). A production
|
||||
deployment would replace this with a real OPA bundle revision (see
|
||||
`../Production-Recommendations.md`); the contract (`PDPDecision.policy_version` as an opaque
|
||||
string) would not change.
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception | Handling by the caller (Gateway) |
|
||||
|---|---|---|
|
||||
| OPA unreachable, timeout, malformed response | `PDPUnavailableError` | Treated as `DENY` (`PDP_UNAVAILABLE`) — never an implicit ALLOW (fail-closed, README.md §38). |
|
||||
| Failure to push a `ClientProfile` at boot | `PDPUnavailableError` | The Gateway does not come up "healthy" — there is no degraded mode without entitlement sync. |
|
||||
|
||||
## How a new policy takes effect
|
||||
|
||||
Editing `config/policies/*.rego` changes the computed `policy_version` (content hash). There is
|
||||
no hot-reload in this RI — restart the Gateway (which restarts the associated OPA process, or
|
||||
point it at an already-running OPA with the updated Rego via `opa run --server config/policies/`).
|
||||
79
RI/docs/interfaces/pdp-opa.md
Normal file
79
RI/docs/interfaces/pdp-opa.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Interface: PDP (OPA)
|
||||
|
||||
Contrato de dados/consulta do Policy Decision Point. Implementação:
|
||||
[`src/emcp_bus/pdp/client.py`](../../src/emcp_bus/pdp/client.py),
|
||||
[`pdp/models.py`](../../src/emcp_bus/pdp/models.py),
|
||||
[`config/policies/entitlement.rego`](../../config/policies/entitlement.rego),
|
||||
[`config/policies/transaction.rego`](../../config/policies/transaction.rego). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#pdp--policy-engine-ep-03).
|
||||
|
||||
## Modelo em dois passos
|
||||
|
||||
`PDPClient.evaluate(client_profile, tool, arguments, customer_classification)` consulta, em
|
||||
ordem, e nunca em paralelo:
|
||||
|
||||
1. **`emcp.entitlement`** (`entitlement.rego`) — `allow` se `tool` está em
|
||||
`data.emcp.client_profiles[client_profile].allowed_tools`. `DENY` aqui é definitivo
|
||||
(`reason_code=NOT_IN_ENTITLEMENT`) — nenhum argumento chega a ser avaliado.
|
||||
2. **`emcp.transaction`** (`transaction.rego`) — só avaliada se o passo 1 permitiu. Restringe por
|
||||
argumento/recurso; nunca pode adicionar uma tool ausente do entitlement. Retorna `allow` e
|
||||
`require_approval` independentemente.
|
||||
|
||||
## Dados versionados no OPA
|
||||
|
||||
`EntitlementManager.sync_profiles_to_pdp()` empurra cada `ClientProfile` validado para
|
||||
`PUT /v1/data/emcp/client_profiles/<profile_name>` no boot — o Rego nunca lê YAML diretamente.
|
||||
|
||||
## Requisição de avaliação
|
||||
|
||||
```
|
||||
POST /v1/data/emcp/entitlement/allow
|
||||
{"input": {"client_profile": "Sales.Write", "tool": "finance.payment.execute"}}
|
||||
```
|
||||
|
||||
```
|
||||
POST /v1/data/emcp/transaction
|
||||
{"input": {
|
||||
"client_profile": "Finance.Payments",
|
||||
"tool": "finance.payment.execute",
|
||||
"arguments": {"amount": 15000, "region": "BR-SP"},
|
||||
"customer_classification": "standard"
|
||||
}}
|
||||
```
|
||||
|
||||
## Resposta combinada (`PDPDecision`)
|
||||
|
||||
```json
|
||||
{"outcome": "ALLOW" | "DENY" | "REQUIRE_APPROVAL", "policy_version": "a1b2c3d4e5f6", "reason_code": "..."}
|
||||
```
|
||||
|
||||
Nunca um booleano isolado — `policy_version` e `reason_code` são obrigatórios em toda resposta.
|
||||
|
||||
| `reason_code` | `outcome` | Quando |
|
||||
|---|---|---|
|
||||
| `NOT_IN_ENTITLEMENT` | `DENY` | Passo 1 negou. |
|
||||
| `TRANSACTION_POLICY_DENIED` | `DENY` | Passo 1 permitiu, passo 2 negou (limite, região, classificação). |
|
||||
| `APPROVAL_THRESHOLD_EXCEEDED` | `REQUIRE_APPROVAL` | `allow=true` e `amount > restrictions.approval_threshold`. |
|
||||
| `ALLOWED` | `ALLOW` | Ambos os passos permitiram, sem exceder o threshold. |
|
||||
|
||||
## `policy_version`
|
||||
|
||||
Um hash SHA-256 (12 caracteres) do conteúdo dos arquivos `*.rego` (exceto `*_test.rego`) em
|
||||
`config/policies/`, computado uma vez no boot do Gateway (`compute_policy_version`). Propagado em
|
||||
toda decisão e evento de auditoria — ver [`audit-events.md`](audit-events.md). Um deployment de
|
||||
produção substituiria isso por uma revisão de bundle OPA real (ver
|
||||
`../Production-Recommendations.md`); o contrato (`PDPDecision.policy_version` como string
|
||||
opaca) não mudaria.
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção | Tratamento pelo caller (Gateway) |
|
||||
|---|---|---|
|
||||
| OPA inalcançável, timeout, resposta malformada | `PDPUnavailableError` | Tratado como `DENY` (`PDP_UNAVAILABLE`) — nunca ALLOW implícito (fail-closed, README.md §38). |
|
||||
| Falha ao empurrar um `ClientProfile` no boot | `PDPUnavailableError` | O Gateway não sobe "saudável" — não há modo degradado sem sincronia de entitlement. |
|
||||
|
||||
## Como uma política nova entra em vigor
|
||||
|
||||
Editar `config/policies/*.rego` muda o `policy_version` computado (hash do conteúdo). Não há
|
||||
hot-reload nesta RI — reiniciar o Gateway (que reinicia o processo OPA associado, ou aponta para
|
||||
um OPA já rodando com o Rego atualizado via `opa run --server config/policies/`).
|
||||
92
RI/docs/interfaces/registry-capability-manifest.en-us.md
Normal file
92
RI/docs/interfaces/registry-capability-manifest.en-us.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Interface: capability manifest and Registry lifecycle
|
||||
|
||||
Capability manifest schema and the publishing pipeline, for whoever is going to publish a new
|
||||
capability to the Global Capability Registry. Implementation:
|
||||
[`src/emcp_bus/registry/models.py`](../../src/emcp_bus/registry/models.py),
|
||||
[`registry/pipeline.py`](../../src/emcp_bus/registry/pipeline.py),
|
||||
[`registry/lifecycle.py`](../../src/emcp_bus/registry/lifecycle.py),
|
||||
[`registry/store.py`](../../src/emcp_bus/registry/store.py). See
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#global-capability-registry-ep-02).
|
||||
|
||||
## Manifest schema (`CapabilityManifest`)
|
||||
|
||||
```yaml
|
||||
# config/capabilities/finance/payment-execute.yaml
|
||||
name: finance.payment.execute # <domain>.<capability>, validated (domain must prefix name)
|
||||
version: 1.0.0 # SemVer
|
||||
domain: finance
|
||||
owner: finance-platform
|
||||
risk_tier: R3 # R0-R4
|
||||
side_effects: true
|
||||
idempotent: false
|
||||
data_classification: [confidential, restricted] # public | internal | confidential | restricted
|
||||
entitlements: [Finance.Payments] # ClientProfile names that may see/call this capability
|
||||
backend:
|
||||
type: mcp # mcp | rest | grpc (only "mcp" has live dispatch in this RI)
|
||||
service: finance-domain # id resolved by config/backends/<service>.yaml
|
||||
approval:
|
||||
required: true # required if risk_tier is in {R3, R4} — pipeline gate
|
||||
```
|
||||
|
||||
(real example: `config/capabilities/finance/payment-execute.yaml`)
|
||||
|
||||
Fields with defaults: `observability.audit_level` (`"full"`), `lifecycle.status`
|
||||
(`"nominate"` — a new manifest never declares itself `active` directly).
|
||||
|
||||
## Lifecycle (`LifecycleStatus`)
|
||||
|
||||
```
|
||||
NOMINATE → REVIEW → REGISTER → ACTIVE ⇄ ACTIVE → DEPRECATED → RETIRED
|
||||
↑________________|
|
||||
(rejected review goes back to NOMINATE)
|
||||
```
|
||||
|
||||
Only an `ACTIVE` capability is resolvable by the Fabric (`CapabilityRouter`) — any other status
|
||||
is a matter exclusively for the control plane. Nothing reaches `ACTIVE` without passing through
|
||||
`REVIEW` (`registry/lifecycle.py:validate_transition`, mechanically verified).
|
||||
|
||||
## Publishing pipeline
|
||||
|
||||
```python
|
||||
pipeline = PublishingPipeline(store)
|
||||
registered = pipeline.submit(manifest) # runs the gates -> NOMINATE -> REVIEW -> REGISTER
|
||||
pipeline.publish(registered.name) # REGISTER -> ACTIVE
|
||||
```
|
||||
|
||||
`submit()` runs business gates before any state transition (schema/naming is already the
|
||||
responsibility of `CapabilityManifest` itself at construction time):
|
||||
|
||||
| Gate | Rule |
|
||||
|---|---|
|
||||
| Owner | `owner` cannot be empty. |
|
||||
| Backend | `backend.service` cannot be empty. |
|
||||
| Risk-based approval | `risk_tier` in `{R3, R4}` requires `approval.required: true`. |
|
||||
|
||||
Any failed gate raises `PublishingRejectedError(reasons)` — nothing enters the Registry
|
||||
partially (fail-closed, no ad hoc manual registration).
|
||||
|
||||
## How the Fabric resolves a capability
|
||||
|
||||
`CapabilityRouter.route(tool)` looks up `tool`'s `ACTIVE` manifest, resolves
|
||||
`backend.service` to a URL+credential via `BackendCredentialProvider` (see
|
||||
[`downstream-identity.md`](downstream-identity.md)), and returns a `BackendRoute`. Without a
|
||||
matching `ACTIVE` manifest, or an unresolvable backend: `UnroutableCapabilityError` — the Gateway
|
||||
treats this as `UNROUTABLE_CAPABILITY` (DENY).
|
||||
|
||||
## Errors
|
||||
|
||||
| Situation | Exception |
|
||||
|---|---|
|
||||
| `name` without a `.` (not `<domain>.<capability>`) | `pydantic.ValidationError` at manifest construction. |
|
||||
| `domain` is not the prefix of `name` | `pydantic.ValidationError` at manifest construction. |
|
||||
| Business gate failed in `submit()` | `PublishingRejectedError` |
|
||||
| Invalid lifecycle transition (e.g. `NOMINATE` → `ACTIVE` directly) | `InvalidLifecycleTransitionError` |
|
||||
| `tool` without an `ACTIVE` manifest, or nonexistent backend | `UnroutableCapabilityError` |
|
||||
|
||||
## Not implemented in this RI
|
||||
|
||||
An administrative HTTP endpoint to publish/revoke via API (today `PublishingPipeline` and the
|
||||
lifecycle transitions are direct Python calls, exercised in tests, not exposed over the
|
||||
network) — see `../Production-Recommendations.md`. A `backend.service` health check as a
|
||||
publishing gate also does not exist (it would be a disguised `TODO` without a real registry of
|
||||
backend health endpoints in this RI).
|
||||
92
RI/docs/interfaces/registry-capability-manifest.md
Normal file
92
RI/docs/interfaces/registry-capability-manifest.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Interface: manifesto de capability e ciclo de vida do Registry
|
||||
|
||||
Schema do manifesto de capability e o pipeline de publicação, para quem vai publicar uma nova
|
||||
capability no Global Capability Registry. Implementação:
|
||||
[`src/emcp_bus/registry/models.py`](../../src/emcp_bus/registry/models.py),
|
||||
[`registry/pipeline.py`](../../src/emcp_bus/registry/pipeline.py),
|
||||
[`registry/lifecycle.py`](../../src/emcp_bus/registry/lifecycle.py),
|
||||
[`registry/store.py`](../../src/emcp_bus/registry/store.py). Ver
|
||||
[`AS-BUILT.md`](../AS-BUILT.md#global-capability-registry-ep-02).
|
||||
|
||||
## Schema do manifesto (`CapabilityManifest`)
|
||||
|
||||
```yaml
|
||||
# config/capabilities/finance/payment-execute.yaml
|
||||
name: finance.payment.execute # <domínio>.<capability>, validado (domain deve prefixar name)
|
||||
version: 1.0.0 # SemVer
|
||||
domain: finance
|
||||
owner: finance-platform
|
||||
risk_tier: R3 # R0-R4
|
||||
side_effects: true
|
||||
idempotent: false
|
||||
data_classification: [confidential, restricted] # public | internal | confidential | restricted
|
||||
entitlements: [Finance.Payments] # nomes de ClientProfile que podem ver/chamar esta capability
|
||||
backend:
|
||||
type: mcp # mcp | rest | grpc (só "mcp" tem dispatch ao vivo nesta RI)
|
||||
service: finance-domain # id resolvido por config/backends/<service>.yaml
|
||||
approval:
|
||||
required: true # obrigatório se risk_tier em {R3, R4} — gate do pipeline
|
||||
```
|
||||
|
||||
(exemplo real: `config/capabilities/finance/payment-execute.yaml`)
|
||||
|
||||
Campos com default: `observability.audit_level` (`"full"`), `lifecycle.status`
|
||||
(`"nominate"` — nunca se declara `active` diretamente num manifesto novo).
|
||||
|
||||
## Ciclo de vida (`LifecycleStatus`)
|
||||
|
||||
```
|
||||
NOMINATE → REVIEW → REGISTER → ACTIVE ⇄ ACTIVE → DEPRECATED → RETIRED
|
||||
↑________________|
|
||||
(review rejeitado volta a NOMINATE)
|
||||
```
|
||||
|
||||
Só uma capability `ACTIVE` é resolvível pelo Fabric (`CapabilityRouter`) — qualquer outro status
|
||||
é um assunto exclusivo do control plane. Nada alcança `ACTIVE` sem passar por `REVIEW`
|
||||
(`registry/lifecycle.py:validate_transition`, verificado mecanicamente).
|
||||
|
||||
## Pipeline de publicação
|
||||
|
||||
```python
|
||||
pipeline = PublishingPipeline(store)
|
||||
registered = pipeline.submit(manifest) # roda os gates -> NOMINATE -> REVIEW -> REGISTER
|
||||
pipeline.publish(registered.name) # REGISTER -> ACTIVE
|
||||
```
|
||||
|
||||
`submit()` roda gates de negócio antes de qualquer transição de estado (schema/naming já é
|
||||
responsabilidade do próprio `CapabilityManifest` na construção):
|
||||
|
||||
| Gate | Regra |
|
||||
|---|---|
|
||||
| Owner | `owner` não pode ser vazio. |
|
||||
| Backend | `backend.service` não pode ser vazio. |
|
||||
| Aprovação por risco | `risk_tier` em `{R3, R4}` exige `approval.required: true`. |
|
||||
|
||||
Qualquer gate reprovado levanta `PublishingRejectedError(reasons)` — nada entra parcialmente no
|
||||
Registry (fail-closed, sem cadastro manual ad hoc).
|
||||
|
||||
## Como o Fabric resolve uma capability
|
||||
|
||||
`CapabilityRouter.route(tool)` busca o manifesto `ACTIVE` de `tool`, resolve
|
||||
`backend.service` para uma URL+credencial via `BackendCredentialProvider` (ver
|
||||
[`downstream-identity.md`](downstream-identity.md)), e retorna um `BackendRoute`. Sem manifesto
|
||||
`ACTIVE` correspondente, ou backend não resolvível: `UnroutableCapabilityError` — o Gateway trata
|
||||
isso como `UNROUTABLE_CAPABILITY` (DENY).
|
||||
|
||||
## Erros
|
||||
|
||||
| Situação | Exceção |
|
||||
|---|---|
|
||||
| `name` sem `.` (não é `<domínio>.<capability>`) | `pydantic.ValidationError` na construção do manifesto. |
|
||||
| `domain` não é o prefixo de `name` | `pydantic.ValidationError` na construção do manifesto. |
|
||||
| Gate de negócio reprovado em `submit()` | `PublishingRejectedError` |
|
||||
| Transição de lifecycle inválida (ex.: `NOMINATE` → `ACTIVE` direto) | `InvalidLifecycleTransitionError` |
|
||||
| `tool` sem manifesto `ACTIVE`, ou backend inexistente | `UnroutableCapabilityError` |
|
||||
|
||||
## Não implementado nesta RI
|
||||
|
||||
Um endpoint HTTP administrativo para publicar/revogar via API (hoje `PublishingPipeline` e as
|
||||
transições de lifecycle são chamadas Python diretas, exercitadas em teste, não expostas via
|
||||
rede) — ver `../Production-Recommendations.md`. Health check de `backend.service` como gate
|
||||
de publicação também não existe (seria um `TODO` disfarçado sem um registro real de endpoints de
|
||||
saúde de backend nesta RI).
|
||||
Reference in New Issue
Block a user