mirror of
https://github.com/mcloh/Enterprise-MCP-Service-Bus.git
synced 2026-11-06 06:24:12 +00:00
M3 implementada e backlog atualizado
This commit is contained in:
0
RI/src/emcp_bus/agent_runtime/__init__.py
Normal file
0
RI/src/emcp_bus/agent_runtime/__init__.py
Normal file
0
RI/src/emcp_bus/agent_runtime/channels/__init__.py
Normal file
0
RI/src/emcp_bus/agent_runtime/channels/__init__.py
Normal file
46
RI/src/emcp_bus/agent_runtime/channels/base.py
Normal file
46
RI/src/emcp_bus/agent_runtime/channels/base.py
Normal 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
|
||||
31
RI/src/emcp_bus/agent_runtime/channels/conversational.py
Normal file
31
RI/src/emcp_bus/agent_runtime/channels/conversational.py
Normal 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,
|
||||
}
|
||||
144
RI/src/emcp_bus/agent_runtime/dispatcher.py
Normal file
144
RI/src/emcp_bus/agent_runtime/dispatcher.py
Normal 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,
|
||||
)
|
||||
78
RI/src/emcp_bus/agent_runtime/handoff_graph.py
Normal file
78
RI/src/emcp_bus/agent_runtime/handoff_graph.py
Normal 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)
|
||||
38
RI/src/emcp_bus/agent_runtime/identity_resolver.py
Normal file
38
RI/src/emcp_bus/agent_runtime/identity_resolver.py
Normal 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()}
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
56
RI/src/emcp_bus/common/pii_redaction.py
Normal file
56
RI/src/emcp_bus/common/pii_redaction.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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(
|
||||
|
||||
0
RI/src/emcp_bus/offering_filter/__init__.py
Normal file
0
RI/src/emcp_bus/offering_filter/__init__.py
Normal file
75
RI/src/emcp_bus/offering_filter/service.py
Normal file
75
RI/src/emcp_bus/offering_filter/service.py
Normal 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
|
||||
0
RI/src/emcp_bus/orchestrator/__init__.py
Normal file
0
RI/src/emcp_bus/orchestrator/__init__.py
Normal file
40
RI/src/emcp_bus/orchestrator/decision_context.py
Normal file
40
RI/src/emcp_bus/orchestrator/decision_context.py
Normal 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))
|
||||
48
RI/src/emcp_bus/orchestrator/decisioning.py
Normal file
48
RI/src/emcp_bus/orchestrator/decisioning.py
Normal 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,
|
||||
)
|
||||
70
RI/src/emcp_bus/orchestrator/fallback.py
Normal file
70
RI/src/emcp_bus/orchestrator/fallback.py
Normal 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)
|
||||
148
RI/src/emcp_bus/orchestrator/graph.py
Normal file
148
RI/src/emcp_bus/orchestrator/graph.py
Normal 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)
|
||||
46
RI/src/emcp_bus/orchestrator/hitl_node.py
Normal file
46
RI/src/emcp_bus/orchestrator/hitl_node.py
Normal 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"}
|
||||
45
RI/src/emcp_bus/orchestrator/nba_model.py
Normal file
45
RI/src/emcp_bus/orchestrator/nba_model.py
Normal 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`."""
|
||||
...
|
||||
49
RI/src/emcp_bus/orchestrator/state.py
Normal file
49
RI/src/emcp_bus/orchestrator/state.py
Normal 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
|
||||
0
RI/src/emcp_bus/profile_intelligence/__init__.py
Normal file
0
RI/src/emcp_bus/profile_intelligence/__init__.py
Normal file
135
RI/src/emcp_bus/profile_intelligence/governance.py
Normal file
135
RI/src/emcp_bus/profile_intelligence/governance.py
Normal 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
|
||||
38
RI/src/emcp_bus/profile_intelligence/models.py
Normal file
38
RI/src/emcp_bus/profile_intelligence/models.py
Normal 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
|
||||
77
RI/src/emcp_bus/profile_intelligence/rule_based_provider.py
Normal file
77
RI/src/emcp_bus/profile_intelligence/rule_based_provider.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user