M3 implementada e backlog atualizado

This commit is contained in:
Marcos Lohmann
2026-09-14 10:11:29 -03:00
parent f3efcc3414
commit c894c1604b
65 changed files with 4261 additions and 74 deletions

View File

@@ -0,0 +1,46 @@
"""Channel adapter contract (EP-12-T02, README.md §3.3, §6.13).
Channel adapters encapsulate differences between experiences (app,
proactive/push, media/campaigns, ...) so the Agent Runtime's dispatch logic
(EP-12-T01) stays channel-agnostic. `ChannelRegistry.adapter_for` fails
loudly (`UnsupportedChannelError`) for an unregistered channel -- an
NBADecision with `channel="sms"` when no SMS adapter is registered must
never be silently dropped or misdelivered to the wrong channel.
"""
from __future__ import annotations
from typing import Any, Protocol
from emcp_bus.agent_runtime.dispatcher import DispatchResult
from emcp_bus.orchestrator.nba_model import NBADecision
class UnsupportedChannelError(Exception):
pass
class ChannelAdapter(Protocol):
channel: str
async def deliver(self, nba: NBADecision, dispatch_result: DispatchResult) -> dict[str, Any]:
"""Formats `dispatch_result` for this channel's delivery mechanism
and returns whatever the channel considers a delivery receipt --
this RI's adapters return a plain dict; a production channel
(push notification, SMS gateway, ...) would return that channel's
own receipt shape instead."""
...
class ChannelRegistry:
def __init__(self, adapters: list[ChannelAdapter]) -> None:
self._adapters = {adapter.channel: adapter for adapter in adapters}
def adapter_for(self, channel: str) -> ChannelAdapter:
adapter = self._adapters.get(channel)
if adapter is None:
raise UnsupportedChannelError(
f"No channel adapter registered for {channel!r} "
f"(registered: {sorted(self._adapters)})"
)
return adapter

View File

@@ -0,0 +1,31 @@
"""Conversational channel adapter (EP-12-T02, README.md §3.3, §6.13).
The one concrete channel adapter this RI demonstrates -- formats a dispatch
result as a conversational message for the `app` channel. A production
deployment would add adapters for push/media/campaign channels the same
way, each registered under its own `channel` value in a `ChannelRegistry`,
without changing `AgentDispatcher` (EP-12-T01) at all.
"""
from __future__ import annotations
from typing import Any
from emcp_bus.agent_runtime.dispatcher import DispatchResult
from emcp_bus.orchestrator.nba_model import NBADecision
class ConversationalChannelAdapter:
channel = "app"
async def deliver(self, nba: NBADecision, dispatch_result: DispatchResult) -> dict[str, Any]:
if dispatch_result.status == "denied":
message = f"I wasn't able to complete {nba.action} right now."
else:
message = f"Done: {nba.action} completed successfully."
return {
"channel": self.channel,
"decision_id": nba.decision_id,
"message": message,
"status": dispatch_result.status,
}

View File

@@ -0,0 +1,144 @@
"""Dispatch service (EP-12-T01, README.md §6.13).
Consumes an `NBADecision` (EP-11-T03) and invokes the MCP Client already
registered for the target agent (EP-01) against the real Gateway -- the
Agent Runtime never generates or stores its own credential; the only
credential this module ever presents to the Gateway is the one the
already-registered MCP Client obtains for itself (`AgentTokenProvider`,
typically a `client_credentials` grant against the same IdP as any other
MCP Client, EP-01-T01). This is the exact same governed path any MCP Client
takes -- `tools/list`/`tools/call` are reauthorized by the Gateway/PDP
(EP-05/EP-03) precisely as if a human-operated client had called them; the
Agent Runtime has no elevated path of its own.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal, Protocol
import httpx
import mcp_types as types
from mcp.client.session import ClientSession
from mcp.client.streamable_http import ( # type: ignore[attr-defined]
create_mcp_http_client,
streamable_http_client,
)
from emcp_bus.orchestrator.nba_model import NBADecision
class UnknownAgentError(Exception):
pass
class AgentCredentialError(Exception):
"""The IdP rejected the registered MCP Client's own credential request --
callers must treat this as a failed dispatch, never fall back to any
other credential (there is none to fall back to)."""
class AgentTokenProvider(Protocol):
async def token_for(self, agent: str) -> str: ...
@dataclass(frozen=True)
class AgentClientCredentials:
client_id: str
client_secret: str
token_endpoint: str
class OIDCAgentTokenProvider:
"""Reference `AgentTokenProvider`: a real `client_credentials` grant per
agent, against whichever IdP that agent's MCP Client is registered with
(EP-01-T01) -- structurally identical to how any other MCP Client
authenticates, because it *is* one."""
def __init__(
self,
credentials_by_agent: dict[str, AgentClientCredentials],
*,
http_client: httpx.AsyncClient | None = None,
) -> None:
self._credentials_by_agent = credentials_by_agent
self._http = http_client or httpx.AsyncClient()
async def aclose(self) -> None:
await self._http.aclose()
async def token_for(self, agent: str) -> str:
credentials = self._credentials_by_agent.get(agent)
if credentials is None:
raise UnknownAgentError(agent)
try:
response = await self._http.post(
credentials.token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": credentials.client_id,
"client_secret": credentials.client_secret,
},
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise AgentCredentialError(
f"Failed to obtain a token for agent {agent!r}: {exc}"
) from exc
token: str = response.json()["access_token"]
return token
@dataclass(frozen=True)
class DispatchResult:
decision_id: str
action: str
status: Literal["allowed", "denied"]
structured_content: dict[str, Any] | None
error_text: str | None = None
class AgentDispatcher:
def __init__(self, *, gateway_url: str, token_provider: AgentTokenProvider) -> None:
self._gateway_url = gateway_url
self._token_provider = token_provider
async def dispatch(self, nba: NBADecision, *, arguments: dict[str, Any]) -> DispatchResult:
"""Calls `nba.action` through the real Gateway, authenticated as the
MCP Client registered for `nba.agent` -- never as a credential this
module invented. Whatever the Gateway/PDP decides (allowed or
denied) is exactly what `DispatchResult` reports; this method never
second-guesses or retries around a denial (that is
`emcp_bus.orchestrator.fallback.handle_denial`'s job, one layer up)."""
token = await self._token_provider.token_for(nba.agent)
async with (
create_mcp_http_client(headers={"Authorization": f"Bearer {token}"}) as http_client,
streamable_http_client(self._gateway_url, http_client=http_client) as (
read_stream,
write_stream,
),
ClientSession(read_stream, write_stream) as session,
):
await session.initialize()
result = await session.call_tool(nba.action, arguments)
if not isinstance(result, types.CallToolResult):
raise TypeError(f"Unexpected dispatch result type: {type(result)!r}")
if result.is_error:
error_text = (
" ".join(getattr(item, "text", "") for item in result.content).strip() or None
)
return DispatchResult(
decision_id=nba.decision_id,
action=nba.action,
status="denied",
structured_content=None,
error_text=error_text,
)
return DispatchResult(
decision_id=nba.decision_id,
action=nba.action,
status="allowed",
structured_content=result.structured_content,
)

View File

@@ -0,0 +1,78 @@
"""Agent-to-agent handoff (EP-12-T03, `could`, README.md §6.13, ADR-022, ADR-023).
Two concerns, kept structurally separate on purpose:
1. **Context minimization** (`build_handoff_payload`): the destination agent
receives only an explicitly declared subset of the journey's context --
never the raw conversation history -- via `carry_keys`.
2. **Independent entitlement resolution**: this module has no code path that
copies, elevates, or otherwise reuses the origin agent's entitlement for
the destination. `execute_handoff` dispatches through the exact same
`AgentDispatcher` (EP-12-T01) any other call uses, which resolves the
destination agent's own MCP Client credential from scratch
(`AgentTokenProvider.token_for(target_agent)`) -- the Gateway/PDP
(EP-05/EP-03) then reauthorizes independently, precisely as ADR-023
requires (the Hoshikawa `agent_id`-in-payload anti-pattern this
guards against).
A full LangGraph subgraph (intra-process handoff via a conditional
back-edge) is natural once a real multi-backend agent demo exists
(EP-13-T05, M4) -- this RI's own journey graph (EP-11) already has exactly
one active agent per run, so there is no intra-process edge to add yet;
this module is the cross-process/cross-backend handoff primitive that
EP-13-T05 will build its subgraph on top of.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from emcp_bus.agent_runtime.dispatcher import DispatchResult
from emcp_bus.orchestrator.nba_model import NBADecision
class Dispatcher(Protocol):
"""What `execute_handoff` needs from `AgentDispatcher` (EP-12-T01) --
narrower than the concrete class, so tests can exercise the
minimization/independent-resolution guarantees below with a stub, and
a real `AgentDispatcher` satisfies it structurally."""
async def dispatch(self, nba: NBADecision, *, arguments: dict[str, Any]) -> DispatchResult: ...
@dataclass(frozen=True)
class HandoffPayload:
decision_id: str
origin_agent: str
target_agent: str
minimal_context: dict[str, Any]
def build_handoff_payload(
*, nba: NBADecision, origin_agent: str, full_context: dict[str, Any], carry_keys: frozenset[str]
) -> HandoffPayload:
"""`full_context` might carry an entire journey's worth of state
(profile, offerings, history, ...) -- only `carry_keys` survives into
`minimal_context` (README.md §6.13: "contexto mínimo necessário)."""
minimal_context = {key: value for key, value in full_context.items() if key in carry_keys}
return HandoffPayload(
decision_id=nba.decision_id,
origin_agent=origin_agent,
target_agent=nba.agent,
minimal_context=minimal_context,
)
async def execute_handoff(
payload: HandoffPayload, nba: NBADecision, *, dispatcher: Dispatcher
) -> DispatchResult:
"""Dispatches to `payload.target_agent` -- `dispatcher` resolves that
agent's own credential independently (EP-12-T01's `AgentTokenProvider`);
nothing here ever reads or forwards `payload.origin_agent`'s credential."""
if nba.agent != payload.target_agent:
raise ValueError(
f"nba.agent {nba.agent!r} does not match the handoff's target_agent "
f"{payload.target_agent!r}"
)
return await dispatcher.dispatch(nba, arguments=payload.minimal_context)

View File

@@ -0,0 +1,38 @@
"""Identity Resolver / BusinessContext (EP-12-T04, ADR-022, ADR-023).
Adopts the Agent Platform OCI's `identity.yaml`/BusinessContext pattern
(ADR-022): declarative aliases mapping a channel-specific field name (e.g.
`msisdn` from an SMS channel payload) to a canonical business key (e.g.
`customer_key`), used to normalize tool-call arguments across channels.
This is purely a naming normalization for the Fabric/MCP parameter mapping
-- it is never, under any circumstance, consulted by the PDP (EP-03). That
is the explicit lesson of ADR-023 (the Hoshikawa `agent_id`-in-payload
anti-pattern): a field resolved from caller-supplied data must never
influence an authorization decision. Nothing in this module imports from
`emcp_bus.pdp` or `emcp_bus.entitlement`, and nothing should ever be added
that does.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
class IdentityResolverConfig(BaseModel):
aliases: dict[str, str]
"""channel field name -> canonical business key, e.g. {"msisdn": "customer_key"}."""
class IdentityResolver:
def __init__(self, config: IdentityResolverConfig) -> None:
self._aliases = config.aliases
def resolve(self, channel_payload: dict[str, Any]) -> dict[str, Any]:
"""Renames every key present in `aliases`; keys absent from
`aliases` pass through unchanged. The result is suitable as
`tools/call` arguments (Fabric/MCP parameter mapping) -- never as
PDP input (see module docstring)."""
return {self._aliases.get(key, key): value for key, value in channel_payload.items()}

View File

@@ -152,3 +152,47 @@ def bypass_attempt_detected(*, backend: str, reason: str) -> EventEnvelope:
category=EventCategory.GRL,
payload={"backend": backend, "reason": reason},
)
def nba_recalculated(
*, subject_ref: str, denied_action: str, new_action: str | None
) -> EventEnvelope:
"""EP-11-T04: the Orchestrator recomputed NBA after a Gateway DENY,
never retried the denied action directly against the Fabric."""
return _base(
event_type="nba_recalculated",
category=EventCategory.IC,
payload={
"subject_ref": subject_ref,
"denied_action": denied_action,
"new_action": new_action,
},
)
def agent_turn_completed(
*, agent: str, tools_attempted: list[str], tools_denied: list[str]
) -> EventEnvelope:
"""EP-13-T01: one business-journey event per agent turn (ADR-022's
hybrid instrumentation -- an automatic OTel span per turn already exists
via common/otel.py; this is the manual, fail-open business event on
top). `tools_denied` is never proof of a bug -- the Gateway/PDP denying
a tool the agent (or a prompt trying to manipulate it) attempted is the
security boundary working as intended (README.md §29, Teste 3 of §45)."""
return _base(
event_type="agent_turn_completed",
category=EventCategory.IC,
client_id=agent,
payload={"tools_attempted": tools_attempted, "tools_denied": tools_denied},
)
def journey_ended_after_denial(*, subject_ref: str, denied_action: str) -> EventEnvelope:
"""EP-11-T04: no remaining offering survived a Gateway DENY -- the
journey ends here, audited, rather than the Orchestrator attempting any
direct Fabric call (README.md §10.1, Axiom 12, P9)."""
return _base(
event_type="journey_ended_after_denial",
category=EventCategory.GRL,
payload={"subject_ref": subject_ref, "denied_action": denied_action},
)

View File

@@ -0,0 +1,56 @@
"""PII/secret redaction before any Langfuse trace export (EP-13-T03, README.md
§27 "Não registrar", seção 6 do superprompt: mandatory before any Langfuse
export).
Pattern-based, not a claim of exhaustive PII detection -- covers the shapes
the RI's own example agent's mocked data can plausibly produce (email, a
Brazilian CPF-shaped number, a phone number) plus the same sensitive-key
heuristic already used for audit payloads (`emcp_bus.audit.models._sanitize`).
A production deployment handling real customer data would replace/extend
the patterns here, not the call sites that use this module -- `redact_text`/
`redact_mapping` are the whole contract.
"""
from __future__ import annotations
import re
from typing import Any
_EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
_CPF_PATTERN = re.compile(r"\b\d{3}\.\d{3}\.\d{3}-\d{2}\b")
_PHONE_PATTERN = re.compile(r"\+?\d{1,3}[\s.-]?\(?\d{2,4}\)?[\s.-]?\d{4,5}[\s.-]?\d{4}\b")
_SENSITIVE_KEY_MARKERS = ("email", "cpf", "ssn", "phone", "token", "secret", "password")
_REDACTION_MARK = "[REDACTED]"
def redact_text(text: str) -> str:
redacted = _EMAIL_PATTERN.sub(_REDACTION_MARK, text)
redacted = _CPF_PATTERN.sub(_REDACTION_MARK, redacted)
redacted = _PHONE_PATTERN.sub(_REDACTION_MARK, redacted)
return redacted
def redact_mapping(payload: dict[str, Any]) -> dict[str, Any]:
"""Recurses into nested dicts/lists; a key that looks sensitive by name
is redacted outright (value never inspected), everything else has its
string values pattern-scanned via `redact_text`."""
result: dict[str, Any] = {}
for key, value in payload.items():
if isinstance(key, str) and any(marker in key.lower() for marker in _SENSITIVE_KEY_MARKERS):
result[key] = _REDACTION_MARK
elif isinstance(value, dict):
result[key] = redact_mapping(value)
elif isinstance(value, list):
result[key] = [
redact_mapping(item)
if isinstance(item, dict)
else (redact_text(item) if isinstance(item, str) else item)
for item in value
]
elif isinstance(value, str):
result[key] = redact_text(value)
else:
result[key] = value
return result

View File

@@ -6,16 +6,23 @@
The Gateway authenticates the *caller*; this module answers a completely
separate question -- which credential the Fabric presents *to the backend*
-- and never answers it by handing the inbound token through unmodified
(README.md §35.6, "Token passthrough indiscriminado"). `service_account`
mode resolves a static, backend-specific credential from an env var at call
time; `token_exchange` (RFC 8693) is a documented, unimplemented extension
(see `BackendConfig`).
(README.md §35.6, "Token passthrough indiscriminado"). Two modes:
`service_account` resolves a static, backend-specific credential from an env
var at call time; `token_exchange` (RFC 8693 via Keycloak's Standard Token
Exchange) is implemented by `TokenExchangeClient` below -- verified
end-to-end against a real Keycloak 26.7.3 instance, 2026-09-14 (see
`downstream/models.py`'s module docstring for the exact IdP-side setup this
requires: the Gateway's own exchange client, a per-backend audience-mapper
client scope).
"""
from __future__ import annotations
import os
import time
from dataclasses import dataclass
import httpx
from pydantic import BaseModel
from emcp_bus.downstream.models import BackendConfig
@@ -26,14 +33,23 @@ class UnknownBackendError(Exception):
class BackendCredentialUnavailableError(Exception):
"""The configured env var for a backend's service-account token is unset.
"""The configured credential source for a backend could not produce a token
at call time -- a missing env var (`service_account` mode) or a failed
RFC 8693 exchange (`token_exchange` mode).
Distinct from `UnknownBackendError`: the backend *is* configured, but
its credential is missing at runtime -- callers (the Gateway) must
treat this as DENY, not as "fall back to no credential" (README.md §38).
Distinct from `UnknownBackendError`: the backend *is* configured, but its
credential is unavailable right now -- callers (the Gateway) must treat
this as DENY, not as "fall back to no credential" (README.md §38).
"""
class TokenExchangeError(Exception):
"""The IdP rejected a `client_credentials` or RFC 8693 exchange call.
Caught and re-raised as `BackendCredentialUnavailableError` by
`BackendCredentialProvider` -- this type exists so `TokenExchangeClient`
itself stays IdP-shaped, not Gateway-failure-policy-shaped."""
class BackendCredential(BaseModel):
backend: str
url: str
@@ -41,21 +57,125 @@ class BackendCredential(BaseModel):
audience: str
class BackendCredentialProvider:
def __init__(self, backend_configs: list[BackendConfig]) -> None:
self._configs = {config.backend: config for config in backend_configs}
@dataclass
class _CachedExchange:
token: str
expires_at: float
def credential_for(self, backend: str) -> BackendCredential:
class TokenExchangeClient:
"""RFC 8693 token exchange against an OIDC IdP's token endpoint (Keycloak
26.2+'s Standard Token Exchange, GA -- no preview feature flag needed).
Two HTTP round trips per fresh exchange: (1) `client_credentials` as the
Gateway's own exchange client, producing a subject_token; (2) exchanging
that subject_token for one scoped to the target backend's `audience`.
Verified empirically against a real Keycloak instance that step 2 fails
with "Requested audience not available" unless the request also carries
a `scope` that actually adds that audience to the token (the `audience`
parameter alone only *restricts*, never *adds*) -- hence
`BackendConfig.token_exchange_scope`.
Exchanged tokens are cached per backend until shortly before their own
`expires_in` (RFC 8693 exchanged tokens are commonly short-lived, and
re-exchanging on every single `tools/call` would be both wasteful and
slow); a cache hit costs zero HTTP round trips.
"""
def __init__(
self,
client_id: str,
client_secret: str,
*,
http_client: httpx.AsyncClient | None = None,
expiry_margin_seconds: float = 5.0,
) -> None:
self._client_id = client_id
self._client_secret = client_secret
self._http = http_client or httpx.AsyncClient()
self._expiry_margin_seconds = expiry_margin_seconds
self._cache: dict[str, _CachedExchange] = {}
async def aclose(self) -> None:
await self._http.aclose()
async def exchange(
self, *, token_endpoint: str, backend: str, audience: str, scope: str
) -> str:
now = time.monotonic()
cached = self._cache.get(backend)
if cached is not None and cached.expires_at > now:
return cached.token
subject_token = await self._client_credentials(token_endpoint)
token, expires_in = await self._exchange(token_endpoint, subject_token, audience, scope)
self._cache[backend] = _CachedExchange(
token=token, expires_at=now + max(expires_in - self._expiry_margin_seconds, 0.0)
)
return token
async def _client_credentials(self, token_endpoint: str) -> str:
try:
response = await self._http.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret,
},
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise TokenExchangeError(
f"Failed to obtain the exchange client's own subject token from "
f"{token_endpoint}: {exc}"
) from exc
access_token: str = response.json()["access_token"]
return access_token
async def _exchange(
self, token_endpoint: str, subject_token: str, audience: str, scope: str
) -> tuple[str, int]:
try:
response = await self._http.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": audience,
"scope": scope,
"client_id": self._client_id,
"client_secret": self._client_secret,
},
)
response.raise_for_status()
except httpx.HTTPError as exc:
raise TokenExchangeError(
f"RFC 8693 token exchange for audience {audience!r} (scope {scope!r}) "
f"at {token_endpoint} failed: {exc}"
) from exc
body = response.json()
return body["access_token"], int(body.get("expires_in", 60))
class BackendCredentialProvider:
def __init__(
self,
backend_configs: list[BackendConfig],
*,
token_exchange_client: TokenExchangeClient | None = None,
) -> None:
self._configs = {config.backend: config for config in backend_configs}
self._token_exchange_client = token_exchange_client
async def credential_for(self, backend: str) -> BackendCredential:
config = self._configs.get(backend)
if config is None:
raise UnknownBackendError(backend)
if config.credential_mode == "token_exchange":
raise NotImplementedError(
f"credential_mode='token_exchange' for backend {backend!r} is a documented "
"extension point (RFC 8693 via an OIDC IdP), not implemented in this RI -- "
"see BackendConfig docstring."
)
return await self._credential_via_token_exchange(backend, config)
assert config.service_account_token_env_var is not None # enforced by BackendConfig
token = os.environ.get(config.service_account_token_env_var)
@@ -68,6 +188,30 @@ class BackendCredentialProvider:
backend=backend, url=config.url, token=token, audience=config.audience
)
async def _credential_via_token_exchange(
self, backend: str, config: BackendConfig
) -> BackendCredential:
if self._token_exchange_client is None:
raise BackendCredentialUnavailableError(
f"Backend {backend!r} is configured for credential_mode='token_exchange' but "
"no TokenExchangeClient is available (GATEWAY_TOKEN_EXCHANGE_CLIENT_ID/"
"_SECRET unset) -- see emcp_bus.downstream.identity module docstring."
)
assert config.token_exchange_endpoint is not None # enforced by BackendConfig
assert config.token_exchange_scope is not None # enforced by BackendConfig
try:
token = await self._token_exchange_client.exchange(
token_endpoint=config.token_exchange_endpoint,
backend=backend,
audience=config.audience,
scope=config.token_exchange_scope,
)
except TokenExchangeError as exc:
raise BackendCredentialUnavailableError(str(exc)) from exc
return BackendCredential(
backend=backend, url=config.url, token=token, audience=config.audience
)
def url_for(self, backend: str) -> str:
config = self._configs.get(backend)
if config is None:

View File

@@ -1,11 +1,19 @@
"""Backend credential config schema (EP-07-T02, README.md §20).
One entry per backend the Fabric calls, declaring *how* the Gateway obtains
an outbound credential for it -- never the credential's value. The RI
implements `service_account` (a static credential resolved from an env var
at call time); `token_exchange` (RFC 8693, e.g. via Keycloak) is modeled as
a distinct, documented mode but not implemented in this milestone -- see
`emcp_bus.downstream.identity.BackendCredentialProvider`.
an outbound credential for it -- never the credential's value. Two modes:
`service_account` (a static credential resolved from an env var at call
time) and `token_exchange` (RFC 8693 via Keycloak's Standard Token Exchange,
GA since Keycloak 26.2 -- verified against a real Keycloak 26.7.3 instance,
2026-09-14: see `emcp_bus.downstream.identity.TokenExchangeClient`). Setting
up `token_exchange` mode for a backend requires, on the real IdP: the
Gateway's own exchange client has `attributes.standard.token.exchange.enabled
= "true"` and is `serviceAccountsEnabled`; a client scope named
`token_exchange_scope` exists with an `oidc-audience-mapper` targeting this
backend's `audience` (verified empirically -- requesting `audience` alone,
without also requesting a scope that actually adds it, fails with "Requested
audience not available"); that scope is a default (or requested optional)
scope of the Gateway's exchange client.
"""
from __future__ import annotations
@@ -42,8 +50,13 @@ class BackendConfig(BaseModel):
static credential. Required when `credential_mode == "service_account"`."""
token_exchange_endpoint: str | None = None
"""RFC 8693 token endpoint URL. Documented extension point, not yet
implemented -- see module docstring."""
"""RFC 8693 token endpoint URL, e.g.
`http://keycloak:8080/realms/emcp/protocol/openid-connect/token`."""
token_exchange_scope: str | None = None
"""Name of the Keycloak client scope (carrying an `oidc-audience-mapper`
for this backend's `audience`) to request alongside the RFC 8693
`audience` parameter -- required, see module docstring."""
@field_validator("service_account_token_env_var")
@classmethod
@@ -66,4 +79,8 @@ class BackendConfig(BaseModel):
raise ValueError(
"token_exchange_endpoint is required when credential_mode == 'token_exchange'"
)
if self.credential_mode == "token_exchange" and not self.token_exchange_scope:
raise ValueError(
"token_exchange_scope is required when credential_mode == 'token_exchange'"
)
return self

View File

@@ -52,11 +52,11 @@ class CapabilityRouter:
self._registry = registry
self._backend_credentials = backend_credentials
def route(self, tool: str) -> BackendRoute:
async def route(self, tool: str) -> BackendRoute:
manifest = self._active_manifest(tool)
backend_ref = manifest.backend
try:
credential = self._backend_credentials.credential_for(backend_ref.service)
credential = await self._backend_credentials.credential_for(backend_ref.service)
except UnknownBackendError as exc:
raise UnroutableCapabilityError(
f"Capability {tool!r} references unknown backend {backend_ref.service!r}"

View File

@@ -71,6 +71,7 @@ from emcp_bus.common.otel import setup_tracing
from emcp_bus.downstream.identity import (
BackendCredentialProvider,
BackendCredentialUnavailableError,
TokenExchangeClient,
UnknownBackendError,
)
from emcp_bus.downstream.models import BackendConfig
@@ -100,6 +101,8 @@ class GatewayConfig:
oidc_issuer: str
oidc_audience: str
audit_log_path: str
token_exchange_client_id: str
token_exchange_client_secret: str
@classmethod
def from_env(cls) -> GatewayConfig:
@@ -110,6 +113,13 @@ class GatewayConfig:
oidc_issuer=os.environ.get("OIDC_ISSUER", ""),
oidc_audience=os.environ.get("OIDC_AUDIENCE", "emcp-gateway"),
audit_log_path=os.environ.get("EMCP_AUDIT_LOG_PATH", "/tmp/emcp-audit.jsonl"),
# EP-07 RFC 8693: the Gateway's own confidential IdP client, used
# to obtain and exchange tokens for backends configured with
# `credential_mode: token_exchange` (downstream/models.py). Empty
# (default) unless set -- backends in `service_account` mode
# (the RI's seed config) never need this.
token_exchange_client_id=os.environ.get("GATEWAY_TOKEN_EXCHANGE_CLIENT_ID", ""),
token_exchange_client_secret=os.environ.get("GATEWAY_TOKEN_EXCHANGE_CLIENT_SECRET", ""),
)
@property
@@ -176,8 +186,14 @@ async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
# to at startup must not come up looking healthy (README.md §38).
await manager.sync_profiles_to_pdp()
token_exchange_client: TokenExchangeClient | None = None
if config.token_exchange_client_id and config.token_exchange_client_secret:
token_exchange_client = TokenExchangeClient(
config.token_exchange_client_id, config.token_exchange_client_secret
)
backend_credentials = BackendCredentialProvider(
load_yaml_models(config.backends_dir, BackendConfig)
load_yaml_models(config.backends_dir, BackendConfig),
token_exchange_client=token_exchange_client,
)
# Fresh Registry per lifespan run (mirrors `manager`/`backend_credentials`
@@ -208,6 +224,8 @@ async def lifespan(server: Server[GatewayState]) -> AsyncIterator[GatewayState]:
finally:
_current_state = None
await pdp.aclose()
if token_exchange_client is not None:
await token_exchange_client.aclose()
registry_db_path.unlink(missing_ok=True)
@@ -278,7 +296,7 @@ async def on_list_tools(
all_tools: list[types.Tool] = []
for backend_id in sorted(state.router.distinct_backends_for(resolved.allowed_tools)):
try:
credential = state.backend_credentials.credential_for(backend_id)
credential = await state.backend_credentials.credential_for(backend_id)
except (UnknownBackendError, BackendCredentialUnavailableError):
continue
downstream_result = await _with_downstream_session(
@@ -384,7 +402,7 @@ async def on_call_tool(
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}:{request.approval_id}")
try:
route = state.router.route(params.name)
route = await state.router.route(params.name)
except UnroutableCapabilityError:
state.audit_sink.emit(
events.tool_call_denied(

View File

@@ -0,0 +1,75 @@
"""Offering Filter (EP-10-T01, README.md §6.11, §7.3, Axiom 8/11).
FilteredOfferings(subject, client, context)
= ActiveCatalog ∩ MaximumEntitlement(client) ∩ SubjectAndContextConstraints(subject, context)
The intersection order is deliberate and the middle term is never widened by
anything to its right: `allowed_tools` (EP-04's `ResolvedEntitlement`, i.e.
MaximumEntitlement) is applied first and is the only term this module treats
as security-relevant -- a `ProfileView` (EP-09) or `ConsentContext` can only
ever *narrow* what already survived that intersection, never reintroduce a
capability the client isn't entitled to (Axiom 8/11, README.md §6.11: "Uma
oferta ausente do maximum entitlement nunca pode ser reintroduzida por score
de perfil.").
`ConsentContext` here models one concrete, real `CapabilityManifest` field --
`data_classification` -- rather than inventing illustrative fields (region,
channel, ...) the manifest schema doesn't actually carry (README.md §6.11's
list is explicitly illustrative, not a fixed contract). A production fork
extending manifests with more context dimensions would extend this
dataclass and the one predicate in `_matches_context`, without touching the
security-relevant intersection above it.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from emcp_bus.registry.models import CapabilityManifest, DataClassification
class ActiveCapabilitySource(Protocol):
"""What `OfferingFilterService` actually needs from a Registry --
narrower than the full `RegistryStore` API (a real `RegistryStore`
satisfies it structurally, no adapter needed), and lets property tests
(EP-10-T02) exercise the filter's own set-arithmetic against a plain
in-memory list of manifests, without paying real SQLite I/O per one of
the 1000+ generated cases."""
def list_active(self) -> list[CapabilityManifest]: ...
@dataclass(frozen=True)
class ConsentContext:
allow_restricted_data: bool = False
"""Whether the subject has consented to capabilities whose
`data_classification` includes `restricted` (README.md §6.11:
"o filtro pode considerar ... consentimento")."""
_NO_CONSENT = ConsentContext()
class OfferingFilterService:
def __init__(self, registry: ActiveCapabilitySource) -> None:
self._registry = registry
def filtered_offerings(
self,
*,
allowed_tools: frozenset[str],
context: ConsentContext = _NO_CONSENT,
) -> list[CapabilityManifest]:
"""`allowed_tools` is `ResolvedEntitlement.allowed_tools`
(`emcp_bus.entitlement.manager`) -- MaximumEntitlement, already
resolved and versioned by EP-04. Never pass anything else here."""
active = self._registry.list_active()
entitled = [manifest for manifest in active if manifest.name in allowed_tools]
return [m for m in entitled if self._matches_context(m, context)]
@staticmethod
def _matches_context(manifest: CapabilityManifest, context: ConsentContext) -> bool:
if context.allow_restricted_data:
return True
return DataClassification.RESTRICTED not in manifest.data_classification

View File

View File

@@ -0,0 +1,40 @@
"""DecisionContext contract (EP-11-T01, README.md §1, §6.12).
Combines everything the Service Orchestrator's decisioning node (EP-11-T03)
needs into one versioned, serializable object: `ProfileView` (EP-09),
resolved MaximumEntitlement (EP-04), `FilteredOfferings` (EP-10), and
runtime context (channel/consent). Nothing downstream of this object can
widen MaximumEntitlement -- `allowed_tools`/`entitlement_version` are
carried through unchanged from EP-04's `ResolvedEntitlement`, and
`filtered_offerings` already went through EP-10's intersection; the
decisioning node only ever ranks/selects among what's already here
(Axiom 8/11).
"""
from __future__ import annotations
from datetime import UTC, datetime
from pydantic import BaseModel, Field
from emcp_bus.common.models import RiskTier
from emcp_bus.profile_intelligence.models import ProfileView
from emcp_bus.registry.models import CapabilityManifest
class RuntimeContext(BaseModel):
channel: str
consent_purposes: frozenset[str] = frozenset()
class DecisionContext(BaseModel):
client_id: str
entitlement_version: str
"""Required -- a DecisionContext cannot be constructed without an
entitlement version to correlate against (EP-11-T01 acceptance criterion)."""
risk_ceiling: RiskTier
allowed_tools: frozenset[str]
profile: ProfileView
filtered_offerings: list[CapabilityManifest]
runtime_context: RuntimeContext
resolved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))

View File

@@ -0,0 +1,48 @@
"""Reference `NBADecisionModel` (EP-11-T03): deterministic rules/priority
over `DecisionContext.filtered_offerings` -- not an AI model, not
clustering. See `nba_model.py`'s module docstring for the pluggable
interface a real ranking model would implement instead, and P7
(docs/RI-PLANNING.md) for why this RI doesn't attempt "real" ranking without
a real dataset.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from emcp_bus.orchestrator.decision_context import DecisionContext
from emcp_bus.orchestrator.nba_model import NBADecision
DEFAULT_AGENT = "engagement-assistant"
DEFAULT_TTL = timedelta(hours=1)
class RuleBasedNBADecisionModel:
"""Picks the lowest-risk-tier offering (least friction first), tie-broken
by name for determinism -- a simple, legible priority rule, not a claim
of optimality. `reason_codes` always includes `ELIGIBLE` plus whatever
`ProfileView.reason_codes` the profile carried (README.md §6.13 example:
`["ELIGIBLE", "ACTIVATION_OBJECTIVE", "FREQUENCY_OK"]`)."""
def decide(self, context: DecisionContext) -> NBADecision | None:
if not context.filtered_offerings:
return None
offering = min(context.filtered_offerings, key=lambda m: (m.risk_tier.value, m.name))
reason_codes = ["ELIGIBLE", *context.profile.reason_codes]
return NBADecision(
decision_id=f"dec-{uuid.uuid4().hex[:12]}",
action=offering.name,
capability_version=offering.version,
subject_ref=context.profile.subject_ref,
channel=context.runtime_context.channel,
agent=DEFAULT_AGENT,
reason_codes=reason_codes,
policy_context={
"entitlementVersion": context.entitlement_version,
"consentPurposes": sorted(context.runtime_context.consent_purposes),
},
expires_at=datetime.now(UTC) + DEFAULT_TTL,
)

View File

@@ -0,0 +1,70 @@
"""Recalculation / safe degradation (EP-11-T04, README.md §10.1, Axiom 12, P9).
When the real Gateway (EP-05) denies the `tools/call` the Agent Runtime
(EP-12) attempted for a previously-issued NBA, the Orchestrator must never
retry the same action directly against the Fabric -- that would just be the
PEP bypassed under a different name. This module's only two outcomes are:
recompute NBA over the *remaining* `filtered_offerings` (the denied action
excluded), or end the journey with an audited reason. There is no code path
here that calls a backend, the Fabric, or the Gateway -- `handle_denial`
only ever returns a new `NBADecision` or `None` for the caller (the journey
graph's `handle_denial_node`, or the Agent Runtime directly) to act on.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from emcp_bus.audit.events import journey_ended_after_denial, nba_recalculated
from emcp_bus.audit.sink import AuditSink
from emcp_bus.orchestrator.decision_context import DecisionContext
from emcp_bus.orchestrator.nba_model import NBADecision, NBADecisionModel
class DenialOutcome(StrEnum):
RECALCULATED = "recalculated"
ENDED = "ended"
@dataclass(frozen=True)
class FallbackResult:
outcome: DenialOutcome
nba: NBADecision | None
def handle_denial(
context: DecisionContext,
*,
denied_action: str,
decision_model: NBADecisionModel,
audit_sink: AuditSink | None = None,
) -> FallbackResult:
"""`context.filtered_offerings` minus `denied_action`, re-run through
`decision_model` -- never a direct call to any backend."""
remaining = [o for o in context.filtered_offerings if o.name != denied_action]
if not remaining:
if audit_sink is not None:
audit_sink.emit(
journey_ended_after_denial(
subject_ref=context.profile.subject_ref, denied_action=denied_action
)
)
return FallbackResult(outcome=DenialOutcome.ENDED, nba=None)
narrowed_context = context.model_copy(update={"filtered_offerings": remaining})
new_nba = decision_model.decide(narrowed_context)
if audit_sink is not None:
audit_sink.emit(
nba_recalculated(
subject_ref=context.profile.subject_ref,
denied_action=denied_action,
new_action=new_nba.action if new_nba is not None else None,
)
)
if new_nba is None:
return FallbackResult(outcome=DenialOutcome.ENDED, nba=None)
return FallbackResult(outcome=DenialOutcome.RECALCULATED, nba=new_nba)

View File

@@ -0,0 +1,148 @@
"""Journey StateGraph (EP-11-T02, README.md §6.12).
Fixed corporate-standard nodes (`docs/research/hoshikawa-agent-platform-oci.md`'s
pattern: dev only adds domain nodes, never touches the backbone):
resolve_profile -> resolve_entitlement -> filter_offerings -> decide_nba
-> approval_gate (EP-11-T05, may pause the graph) -> END
Plus a second entry path for the denial-recalculation loop (EP-11-T04):
re-invoking the graph with `denied_action` set routes straight to
`handle_denial_node` (skipping profile/entitlement/offering resolution,
which haven't changed) -> `decide_nba` again over the narrowed offerings ->
`approval_gate` -> END, or straight to END if no offering remains.
Checkpointing (this task's other acceptance criterion): pass any LangGraph
`Checkpointer` (`langgraph.checkpoint.sqlite.SqliteSaver` in dev, per
ADR-022's plugable-provider pattern -- a `postgres`/`mongodb` one is a
drop-in swap in production, no graph code changes). A real IdP-integrated
deployment resumes a paused `approval_gate` by calling
`graph.invoke(Command(resume="approved"), config)` once
`emcp_bus.approval.service.ApprovalService.grant()` reports the matching
approval as granted (EP-11-T05's docstring) -- wiring that callback is a
thin adapter, not shown here since it depends on how a deployment surfaces
approvals (CLI, API, ApprovalService callback), not on this graph's shape.
"""
from __future__ import annotations
from typing import Any, Literal
from langgraph.graph import END, START, StateGraph
from langgraph.graph.state import CompiledStateGraph
from emcp_bus.audit.sink import AuditSink
from emcp_bus.common.models import RiskTier
from emcp_bus.entitlement.manager import EntitlementManager
from emcp_bus.offering_filter.service import ConsentContext, OfferingFilterService
from emcp_bus.orchestrator.decision_context import DecisionContext, RuntimeContext
from emcp_bus.orchestrator.fallback import handle_denial
from emcp_bus.orchestrator.hitl_node import approval_gate_node, requires_approval
from emcp_bus.orchestrator.nba_model import NBADecisionModel
from emcp_bus.orchestrator.state import JourneyState
from emcp_bus.profile_intelligence.rule_based_provider import ProfileIntelligenceProvider
def _decision_context_from_state(
state: JourneyState, *, exclude: frozenset[str] = frozenset()
) -> DecisionContext:
offerings = [o for o in state["filtered_offerings"] if o.name not in exclude]
return DecisionContext(
client_id=state["client_id"],
entitlement_version=state["entitlement_version"],
risk_ceiling=RiskTier(state["risk_ceiling"]),
allowed_tools=frozenset(state["allowed_tools"]),
profile=state["profile"],
filtered_offerings=offerings,
runtime_context=RuntimeContext(channel=state["channel"]),
)
def build_journey_graph(
*,
profile_provider: ProfileIntelligenceProvider,
entitlement_manager: EntitlementManager,
offering_filter: OfferingFilterService,
decision_model: NBADecisionModel,
checkpointer: Any,
audit_sink: AuditSink | None = None,
consent_context: ConsentContext | None = None,
) -> CompiledStateGraph[JourneyState, None, JourneyState, JourneyState]:
consent_context = consent_context or ConsentContext()
def resolve_profile_node(state: JourneyState) -> dict[str, Any]:
profile = profile_provider.resolve(state["raw_subject_id"])
return {"profile": profile}
def resolve_entitlement_node(state: JourneyState) -> dict[str, Any]:
resolved = entitlement_manager.resolve(state["client_id"])
return {
"allowed_tools": sorted(resolved.allowed_tools),
"entitlement_version": resolved.entitlement_version,
"risk_ceiling": resolved.risk_ceiling.value,
}
def filter_offerings_node(state: JourneyState) -> dict[str, Any]:
offerings = offering_filter.filtered_offerings(
allowed_tools=frozenset(state["allowed_tools"]), context=consent_context
)
return {"filtered_offerings": offerings}
def decide_nba_node(state: JourneyState) -> dict[str, Any]:
excluded = frozenset(state.get("excluded_actions", []))
context = _decision_context_from_state(state, exclude=excluded)
nba = decision_model.decide(context)
needs_approval = nba is not None and requires_approval(
context.filtered_offerings, nba.action
)
return {"nba": nba, "nba_requires_approval": needs_approval}
def handle_denial_node(state: JourneyState) -> dict[str, Any]:
denied_action = state["denied_action"]
assert denied_action is not None
already_excluded = frozenset(state.get("excluded_actions", []))
context = _decision_context_from_state(state, exclude=already_excluded)
result = handle_denial(
context,
denied_action=denied_action,
decision_model=decision_model,
audit_sink=audit_sink,
)
needs_approval = result.nba is not None and requires_approval(
context.filtered_offerings, result.nba.action
)
return {
"nba": result.nba,
"nba_requires_approval": needs_approval,
"excluded_actions": [*already_excluded, denied_action],
"denial_outcome": result.outcome.value,
"denied_action": None,
}
def route_entry(state: JourneyState) -> Literal["fresh", "denial_reentry"]:
return "denial_reentry" if state.get("denied_action") else "fresh"
def route_after_denial(state: JourneyState) -> str:
return "approval_gate" if state.get("nba") is not None else END
builder: StateGraph[JourneyState, None, JourneyState, JourneyState] = StateGraph(JourneyState)
builder.add_node("resolve_profile", resolve_profile_node)
builder.add_node("resolve_entitlement", resolve_entitlement_node)
builder.add_node("filter_offerings", filter_offerings_node)
builder.add_node("decide_nba", decide_nba_node)
builder.add_node("approval_gate", approval_gate_node)
builder.add_node("handle_denial", handle_denial_node)
builder.add_conditional_edges(
START, route_entry, {"fresh": "resolve_profile", "denial_reentry": "handle_denial"}
)
builder.add_edge("resolve_profile", "resolve_entitlement")
builder.add_edge("resolve_entitlement", "filter_offerings")
builder.add_edge("filter_offerings", "decide_nba")
builder.add_edge("decide_nba", "approval_gate")
builder.add_conditional_edges(
"handle_denial", route_after_denial, {"approval_gate": "approval_gate", END: END}
)
builder.add_edge("approval_gate", END)
return builder.compile(checkpointer=checkpointer)

View File

@@ -0,0 +1,46 @@
"""Human-in-the-loop interrupt node (EP-11-T05, README.md §26, integrates EP-14).
Pauses the journey graph (LangGraph's `interrupt`) when the NBA's target
capability requires approval (`CapabilityManifest.approval.required`) --
the same signal the Gateway/PDP (EP-05/EP-14) enforce independently at
`tools/call` time, checked here *before* the Agent Runtime (EP-12) ever
attempts dispatch, so a high-risk NBA doesn't even reach the point of a
denied call.
No silent timeout: `interrupt()` suspends the graph and LangGraph persists
that via the checkpointer (verified in
tests/unit/test_orchestrator_graph.py) -- there is no code path here, or
anywhere in this module, that turns "no answer yet" into an implicit
approval. Resuming is the caller's job, via `Command(resume=...)` once
EP-14's `ApprovalService` reports a grant -- see `graph.py`'s module
docstring for how the two are wired together in a real deployment.
"""
from __future__ import annotations
from typing import Any
from langgraph.types import interrupt
from emcp_bus.orchestrator.state import JourneyState
def requires_approval(offerings: list[Any], action: str) -> bool:
return any(o.name == action and o.approval.required for o in offerings)
def approval_gate_node(state: JourneyState) -> dict[str, Any]:
nba = state.get("nba")
if nba is None:
return {"approval_status": "not_applicable"}
if not state.get("nba_requires_approval", False):
return {"approval_status": "not_required"}
decision = interrupt(
{
"approval_for": nba.action,
"decision_id": nba.decision_id,
"reason_codes": nba.reason_codes,
}
)
return {"approval_status": "approved" if decision == "approved" else "denied"}

View File

@@ -0,0 +1,45 @@
"""NBA/NBO contract + pluggable decision interface (EP-11-T03, README.md
§6.13, ADR-024).
Field names/shape mirror README.md §6.13's dispatch example exactly
(`decisionId`, `action`, `capabilityVersion`, `subjectRef`, `channel`,
`agent`, `reasonCodes`, `policyContext`, `expiresAt`).
`NBADecisionModel` is a `Protocol` on purpose (ADR-024, EP-11-T03 acceptance
criterion): swapping the reference rule-based implementation
(`decisioning.RuleBasedNBADecisionModel`) for an AI ranking/recommendation
model changes nothing about `DecisionContext` in or `NBADecision` out, and
therefore nothing about EP-03/EP-04/EP-05's authorization surface -- the
model only ever ranks/selects among `DecisionContext.filtered_offerings`,
already intersected with MaximumEntitlement by EP-10 before this code ever
sees it (Axiom 8/11).
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Protocol
from pydantic import BaseModel
from emcp_bus.orchestrator.decision_context import DecisionContext
class NBADecision(BaseModel):
decision_id: str
action: str
capability_version: str
subject_ref: str
channel: str
agent: str
reason_codes: list[str]
policy_context: dict[str, Any]
expires_at: datetime
class NBADecisionModel(Protocol):
def decide(self, context: DecisionContext) -> NBADecision | None:
"""`None` when no offering survives `context.filtered_offerings` --
the caller (the journey graph, EP-11-T02) must treat this as "end
the journey," never as an excuse to look outside `filtered_offerings`."""
...

View File

@@ -0,0 +1,49 @@
"""Journey state (EP-11-T02, README.md §6.12).
A `TypedDict` (LangGraph's expected state-schema shape) carrying the journey
through the graph's fixed nodes: `resolve_profile -> resolve_entitlement ->
filter_offerings -> decide_nba -> approval_gate` (`graph.py`), plus the
denial-recalculation loop (EP-11-T04, `handle_denial_node`). `total=False`
because every field is populated incrementally, one node at a time -- the
graph's whole point is that a checkpoint mid-journey has some fields set and
others not yet.
"""
from __future__ import annotations
from typing import TypedDict
from emcp_bus.orchestrator.nba_model import NBADecision
from emcp_bus.profile_intelligence.models import ProfileView
from emcp_bus.registry.models import CapabilityManifest
class JourneyState(TypedDict, total=False):
# Inputs (set by the caller before the first `invoke`).
client_id: str
raw_subject_id: str
channel: str
# Populated by resolve_profile.
profile: ProfileView
# Populated by resolve_entitlement.
allowed_tools: list[str]
entitlement_version: str
risk_ceiling: str
# Populated by filter_offerings.
filtered_offerings: list[CapabilityManifest]
# Populated by decide_nba.
nba: NBADecision | None
nba_requires_approval: bool
# Populated by approval_gate (EP-11-T05).
approval_status: str
# Denial-recalculation loop (EP-11-T04) -- set by the caller (the Agent
# Runtime, EP-12) when re-invoking the graph after a real Gateway DENY.
denied_action: str | None
excluded_actions: list[str]
denial_outcome: str | None

View File

@@ -0,0 +1,135 @@
"""Data-governance guardrails for Profile Intelligence (EP-09-T03, README.md
§6.10, Threat: Sensitive attribute leakage §28).
Three requirements from README §6.10's governance list, made concrete and
testable: pseudonymized subject identifiers, a lineage record per resolution
(features/version/window), and a drift-monitor *stub* -- explicitly not real
statistical drift detection (P7: no real dataset to detect drift against in
this RI), just the extension point a production fork would wire a real one
into.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from datetime import UTC, datetime
from emcp_bus.profile_intelligence.models import ProfileView
_SENSITIVE_ATTRIBUTE_KEY_MARKERS = (
"email",
"cpf",
"ssn",
"phone",
"name",
"address",
"birthdate",
"document",
)
def pseudonymize_subject(raw_identifier: str) -> str:
"""A raw identifier (customer id, email, msisdn, ...) is never carried
into a `ProfileView` -- only this deterministic pseudonym is."""
digest = hashlib.sha256(raw_identifier.encode("utf-8")).hexdigest()
return f"subject:{digest[:6]}"
class SensitiveAttributeLeakageError(Exception):
"""A `ProfileView` carries a raw (non-pseudonymized) subject reference,
or an `attributes` key that looks like direct PII -- README.md §28's
"Sensitive attribute leakage" threat, made a hard failure rather than a
lint warning."""
def assert_no_raw_pii(profile_view: ProfileView) -> None:
if not profile_view.subject_ref.startswith("subject:"):
raise SensitiveAttributeLeakageError(
f"ProfileView.subject_ref {profile_view.subject_ref!r} is not pseudonymized "
"(expected 'subject:<hash>', see pseudonymize_subject)"
)
for key in profile_view.attributes:
lowered = key.lower()
if any(marker in lowered for marker in _SENSITIVE_ATTRIBUTE_KEY_MARKERS):
raise SensitiveAttributeLeakageError(
f"ProfileView.attributes key {key!r} looks like direct PII, not a "
"derived/synthetic attribute (README.md §28)"
)
@dataclass(frozen=True)
class FeatureLineageRecord:
"""Documents *why* a ProfileView looks the way it does -- README.md
§6.10: "documentar features, finalidade, janela temporal e qualidade"."""
subject_ref: str
profile_version: str
features: tuple[str, ...]
purpose: str
time_window: str
resolved_at: datetime = field(default_factory=lambda: datetime.now(UTC))
class FeatureLineageLog:
"""In-memory append-only lineage log -- a JSONL/DB-backed sink is a
natural production extension (same shape as `emcp_bus.audit.sink`), not
needed for this RI's demonstration scope."""
def __init__(self) -> None:
self.records: list[FeatureLineageRecord] = []
def record(
self,
profile_view: ProfileView,
*,
features: tuple[str, ...],
purpose: str,
time_window: str,
) -> FeatureLineageRecord:
entry = FeatureLineageRecord(
subject_ref=profile_view.subject_ref,
profile_version=profile_view.profile_version,
features=features,
purpose=purpose,
time_window=time_window,
)
self.records.append(entry)
return entry
@dataclass(frozen=True)
class DriftReport:
segment_id: str
baseline_score: float
observed_score: float
drifted: bool
class DriftMonitor:
"""Stub (README.md §6.10: "monitorar drift, estabilidade e impacto
desigual") -- flags a segment whose score moved further than
`threshold` from a caller-supplied baseline. Not real statistical drift
detection (no population-level distribution tracking); a production
fork with real telemetry would replace this, not extend it.
"""
def __init__(self, *, threshold: float = 0.3) -> None:
self._threshold = threshold
def check(self, profile_view: ProfileView, baseline: dict[str, float]) -> list[DriftReport]:
reports = []
for segment in profile_view.segments:
baseline_score = baseline.get(segment.id)
if baseline_score is None:
continue
delta = abs(segment.score - baseline_score)
reports.append(
DriftReport(
segment_id=segment.id,
baseline_score=baseline_score,
observed_score=segment.score,
drifted=delta > self._threshold,
)
)
return reports

View File

@@ -0,0 +1,38 @@
"""ProfileView contract (EP-09-T01, README.md §6.10, Axiom 11).
Field names and shape follow the README §6.10 example exactly (`subjectRef`,
`profileVersion`, `segments`, `attributes`, `reasonCodes`, `expiresAt`) --
"Os nomes acima são ilustrativos. A arquitetura não prescreve taxonomia,
algoritmo, número de clusters ou atributos específicos", so this model is
deliberately generic: any `ProfileIntelligenceProvider` implementation
(rule-based here, EP-09-T02; a real clustering/ML model in a production
fork) produces the same contract. A `ProfileView` only ever *orders or
reduces* candidates for the Offering Filter (EP-10) -- it is never consulted
by the PDP (EP-03) and can never grant a capability outside
MaximumEntitlement (Axiom 11): nothing in this module, or anything that
consumes it, is wired into the authorization path.
"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class Segment(BaseModel):
id: str
score: float = Field(ge=0.0, le=1.0)
class ProfileView(BaseModel):
subject_ref: str = Field(
description="Pseudonymized subject identifier, e.g. 'subject:7f31c2' -- "
"never a raw PII identifier (README.md §6.10 governance requirements, "
"EP-09-T03's guardrail)."
)
profile_version: str
segments: list[Segment] = Field(default_factory=list)
attributes: dict[str, str] = Field(default_factory=dict)
reason_codes: list[str] = Field(default_factory=list)
expires_at: datetime

View File

@@ -0,0 +1,77 @@
"""Reference `ProfileIntelligenceProvider` (EP-09-T02, README.md §6.10, P7).
A deterministic, rules-over-synthetic-attributes stub -- not clustering, not
a trained model, not a proprietary taxonomy (P7: this RI does not attempt
"real" clustering without a real dataset, see docs/RI-PLANNING.md's gap
resolution). `ProfileIntelligenceProvider` is a `Protocol` so a production
fork can swap in a real ML-backed implementation without touching any
consumer (the Offering Filter, EP-10) -- the contract is `ProfileView` in,
`ProfileView` out.
Determinism (EP-09-T02 acceptance criterion: same subject_ref + same time
window -> same profileVersion) comes from hashing `(subject_ref, window)`
rather than anything time-of-call-dependent -- two resolutions in the same
ISO week for the same subject always agree, which is what a demo needs to
look "sensible" across repeated calls without a real model or a database of
prior scores.
"""
from __future__ import annotations
import hashlib
from datetime import UTC, datetime, timedelta
from typing import Protocol
from emcp_bus.profile_intelligence.models import ProfileView, Segment
_ENGAGEMENT_BANDS = ("low", "medium", "high")
_CHANNEL_POOLS = (["app"], ["app", "email"], ["email", "sms"], ["app", "sms"])
class ProfileIntelligenceProvider(Protocol):
def resolve(self, subject_ref: str, *, as_of: datetime | None = None) -> ProfileView: ...
class RuleBasedProfileProvider:
"""Reference implementation: `Segment` scores and `attributes` are
derived purely from a hash of `(subject_ref, iso_week)` -- no external
data source, no learned parameters. Good enough to demonstrate the
contract (EP-10's Offering Filter, EP-11's DecisionContext) without
claiming any real personalization intelligence.
"""
def __init__(self, *, ttl: timedelta = timedelta(hours=24)) -> None:
self._ttl = ttl
def resolve(self, subject_ref: str, *, as_of: datetime | None = None) -> ProfileView:
as_of = as_of or datetime.now(UTC)
iso_year, iso_week, _ = as_of.isocalendar()
window = f"{iso_year}-W{iso_week:02d}"
digest = hashlib.sha256(f"{subject_ref}:{window}".encode()).hexdigest()
engagement_band = _ENGAGEMENT_BANDS[int(digest[0], 16) % len(_ENGAGEMENT_BANDS)]
preferred_channels = _CHANNEL_POOLS[int(digest[1], 16) % len(_CHANNEL_POOLS)]
low_engagement_score = int(digest[2:4], 16) / 255
digital_preference_score = int(digest[4:6], 16) / 255
reason_codes = []
if low_engagement_score > 0.5:
reason_codes.append("LOW_RECENCY")
if "app" in preferred_channels:
reason_codes.append("APP_AFFINITY")
return ProfileView(
subject_ref=subject_ref,
profile_version=f"rule-based-{window}-{digest[:8]}",
segments=[
Segment(id="low-engagement", score=round(low_engagement_score, 2)),
Segment(id="digital-preference", score=round(digital_preference_score, 2)),
],
attributes={
"relationshipStage": "activation",
"preferredChannels": ",".join(preferred_channels),
"engagementBand": engagement_band,
},
reason_codes=reason_codes,
expires_at=as_of + self._ttl,
)