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