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

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