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,67 @@
"""Channel adapters (EP-12-T02, README.md §3.3/§6.13)."""
from __future__ import annotations
from datetime import datetime
import pytest
from emcp_bus.agent_runtime.channels.base import ChannelRegistry, UnsupportedChannelError
from emcp_bus.agent_runtime.channels.conversational import ConversationalChannelAdapter
from emcp_bus.agent_runtime.dispatcher import DispatchResult
from emcp_bus.orchestrator.nba_model import NBADecision
def _nba(channel: str = "app") -> NBADecision:
return NBADecision(
decision_id="dec-1",
action="sales.customer.get",
capability_version="1.0.0",
subject_ref="subject:abc123",
channel=channel,
agent="engagement-assistant",
reason_codes=["ELIGIBLE"],
policy_context={},
expires_at=datetime(2026, 1, 1),
)
def test_channel_registry_routes_to_the_matching_adapter() -> None:
registry = ChannelRegistry([ConversationalChannelAdapter()])
adapter = registry.adapter_for("app")
assert adapter.channel == "app"
def test_channel_registry_fails_explicitly_for_an_unsupported_channel() -> None:
registry = ChannelRegistry([ConversationalChannelAdapter()])
with pytest.raises(UnsupportedChannelError):
registry.adapter_for("sms")
async def test_conversational_adapter_reports_success() -> None:
adapter = ConversationalChannelAdapter()
dispatch_result = DispatchResult(
decision_id="dec-1",
action="sales.customer.get",
status="allowed",
structured_content={"ok": True},
)
delivery = await adapter.deliver(_nba(), dispatch_result)
assert delivery["status"] == "allowed"
assert delivery["channel"] == "app"
async def test_conversational_adapter_reports_denial() -> None:
adapter = ConversationalChannelAdapter()
dispatch_result = DispatchResult(
decision_id="dec-1", action="sales.customer.get", status="denied", structured_content=None
)
delivery = await adapter.deliver(_nba(), dispatch_result)
assert delivery["status"] == "denied"

View File

@@ -0,0 +1,105 @@
"""Agent-to-agent handoff (EP-12-T03, `could`, README.md §6.13, ADR-023)."""
from __future__ import annotations
from datetime import datetime
from typing import Any
import pytest
from emcp_bus.agent_runtime.dispatcher import DispatchResult
from emcp_bus.agent_runtime.handoff_graph import build_handoff_payload, execute_handoff
from emcp_bus.orchestrator.nba_model import NBADecision
def _nba(agent: str) -> NBADecision:
return NBADecision(
decision_id="dec-1",
action="finance.invoice.get",
capability_version="1.0.0",
subject_ref="subject:abc123",
channel="app",
agent=agent,
reason_codes=["ELIGIBLE"],
policy_context={},
expires_at=datetime(2026, 1, 1),
)
def test_build_handoff_payload_only_carries_declared_keys() -> None:
nba = _nba("finance-specialist")
full_context = {
"customer_key": "cust-001",
"conversation_history": ["a very long transcript..."],
"internal_debug_state": {"secret": "irrelevant"},
}
payload = build_handoff_payload(
nba=nba,
origin_agent="engagement-assistant",
full_context=full_context,
carry_keys=frozenset({"customer_key"}),
)
assert payload.minimal_context == {"customer_key": "cust-001"}
assert "conversation_history" not in payload.minimal_context
assert "internal_debug_state" not in payload.minimal_context
def test_build_handoff_payload_records_origin_and_target() -> None:
nba = _nba("finance-specialist")
payload = build_handoff_payload(
nba=nba, origin_agent="engagement-assistant", full_context={}, carry_keys=frozenset()
)
assert payload.origin_agent == "engagement-assistant"
assert payload.target_agent == "finance-specialist"
assert payload.decision_id == nba.decision_id
class _RecordingDispatcher:
def __init__(self) -> None:
self.calls: list[tuple[NBADecision, dict[str, Any]]] = []
async def dispatch(self, nba: NBADecision, *, arguments: dict[str, Any]) -> DispatchResult:
self.calls.append((nba, arguments))
return DispatchResult(
decision_id=nba.decision_id, action=nba.action, status="allowed", structured_content={}
)
async def test_execute_handoff_dispatches_with_the_targets_own_nba_and_minimal_context() -> None:
"""The dispatcher call carries `nba` (whose `.agent` is the *target*) --
`AgentDispatcher.dispatch` (EP-12-T01) resolves that agent's own
credential from scratch. Nothing here ever touches the origin agent's
token."""
nba = _nba("finance-specialist")
payload = build_handoff_payload(
nba=nba,
origin_agent="engagement-assistant",
full_context={"customer_key": "cust-001", "history": "..."},
carry_keys=frozenset({"customer_key"}),
)
dispatcher = _RecordingDispatcher()
result = await execute_handoff(payload, nba, dispatcher=dispatcher)
assert result.status == "allowed"
assert len(dispatcher.calls) == 1
called_nba, called_arguments = dispatcher.calls[0]
assert called_nba.agent == "finance-specialist"
assert called_arguments == {"customer_key": "cust-001"}
async def test_execute_handoff_rejects_a_mismatched_nba() -> None:
nba_for_a_different_agent = _nba("some-other-agent")
payload = build_handoff_payload(
nba=_nba("finance-specialist"),
origin_agent="engagement-assistant",
full_context={},
carry_keys=frozenset(),
)
with pytest.raises(ValueError, match="does not match"):
await execute_handoff(payload, nba_for_a_different_agent, dispatcher=_RecordingDispatcher())

View File

@@ -0,0 +1,55 @@
"""Identity Resolver / BusinessContext (EP-12-T04, ADR-022, ADR-023)."""
from __future__ import annotations
from pathlib import Path
from emcp_bus.agent_runtime.identity_resolver import IdentityResolver, IdentityResolverConfig
from emcp_bus.common.config import load_yaml_model
REPO_ROOT = Path(__file__).parents[2]
IDENTITY_YAML = REPO_ROOT / "config" / "orchestrator" / "identity.yaml"
def test_resolve_maps_a_declared_alias_to_its_canonical_key() -> None:
resolver = IdentityResolver(IdentityResolverConfig(aliases={"msisdn": "customer_key"}))
result = resolver.resolve({"msisdn": "+5511999999999"})
assert result == {"customer_key": "+5511999999999"}
def test_resolve_passes_through_unaliased_keys_unchanged() -> None:
resolver = IdentityResolver(IdentityResolverConfig(aliases={"msisdn": "customer_key"}))
result = resolver.resolve({"amount": 100})
assert result == {"amount": 100}
def test_the_real_seed_identity_yaml_validates_and_resolves_msisdn() -> None:
config = load_yaml_model(IDENTITY_YAML, IdentityResolverConfig)
resolver = IdentityResolver(config)
result = resolver.resolve({"msisdn": "+5511999999999"})
assert result == {"customer_key": "+5511999999999"}
def test_identity_resolver_module_never_imports_pdp_or_entitlement() -> None:
"""ADR-023's guardrail, made mechanically checkable: this module must
never gain a dependency on the authorization path."""
import ast
import emcp_bus.agent_runtime.identity_resolver as module
source = Path(module.__file__).read_text(encoding="utf-8")
imported_modules = set()
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.ImportFrom) and node.module:
imported_modules.add(node.module)
elif isinstance(node, ast.Import):
imported_modules.update(alias.name for alias in node.names)
assert not any(name.startswith("emcp_bus.pdp") for name in imported_modules)
assert not any(name.startswith("emcp_bus.entitlement") for name in imported_modules)

View File

@@ -2,12 +2,14 @@
from __future__ import annotations
import httpx
import pytest
from pydantic import ValidationError
from emcp_bus.downstream.identity import (
BackendCredentialProvider,
BackendCredentialUnavailableError,
TokenExchangeClient,
UnknownBackendError,
)
from emcp_bus.downstream.models import BackendConfig
@@ -38,10 +40,22 @@ def test_backend_config_requires_endpoint_for_token_exchange_mode() -> None:
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
credential_mode="token_exchange",
token_exchange_scope="aud-sales-domain",
)
def test_credential_for_configured_backend_resolves_from_env(
def test_backend_config_requires_scope_for_token_exchange_mode() -> None:
with pytest.raises(ValidationError):
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
credential_mode="token_exchange",
token_exchange_endpoint="https://keycloak.example/realms/emcp/protocol/openid-connect/token",
)
async def test_credential_for_configured_backend_resolves_from_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SALES_DOMAIN_SERVICE_TOKEN", "backend-secret-token")
@@ -56,13 +70,13 @@ def test_credential_for_configured_backend_resolves_from_env(
]
)
credential = provider.credential_for("sales-domain")
credential = await provider.credential_for("sales-domain")
assert credential.token == "backend-secret-token"
assert credential.audience == "sales-domain-service"
def test_credential_differs_from_a_hypothetical_inbound_client_token(
async def test_credential_differs_from_a_hypothetical_inbound_client_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The property README.md §20/ADR-006 actually cares about: whatever the
@@ -80,20 +94,20 @@ def test_credential_differs_from_a_hypothetical_inbound_client_token(
)
inbound_client_token = "inbound-oidc-access-token" # nosec -- test fixture, not a real secret
credential = provider.credential_for("sales-domain")
credential = await provider.credential_for("sales-domain")
assert credential.token != inbound_client_token
assert credential.audience != "emcp-gateway" # the Gateway's own inbound audience
def test_unknown_backend_raises() -> None:
async def test_unknown_backend_raises() -> None:
provider = BackendCredentialProvider([])
with pytest.raises(UnknownBackendError):
provider.credential_for("no-such-backend")
await provider.credential_for("no-such-backend")
def test_missing_env_var_at_call_time_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_missing_env_var_at_call_time_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("SALES_DOMAIN_SERVICE_TOKEN", raising=False)
provider = BackendCredentialProvider(
[
@@ -107,21 +121,104 @@ def test_missing_env_var_at_call_time_fails_closed(monkeypatch: pytest.MonkeyPat
)
with pytest.raises(BackendCredentialUnavailableError):
provider.credential_for("sales-domain")
await provider.credential_for("sales-domain")
def test_token_exchange_mode_is_a_documented_but_unimplemented_extension() -> None:
# --- token_exchange mode (EP-07, RFC 8693) ---------------------------------
_TOKEN_EXCHANGE_CONFIG = BackendConfig(
backend="finance-domain",
url="http://127.0.0.1:8101/mcp",
audience="finance-domain-service",
credential_mode="token_exchange",
token_exchange_endpoint="https://keycloak.example/realms/emcp/protocol/openid-connect/token",
token_exchange_scope="aud-finance-domain-service",
)
async def test_token_exchange_mode_without_a_configured_client_fails_closed() -> None:
"""A backend declared as `token_exchange` but with no
GATEWAY_TOKEN_EXCHANGE_CLIENT_ID/_SECRET configured (no `TokenExchangeClient`
passed to the provider) must DENY, not silently fall back to something else."""
provider = BackendCredentialProvider([_TOKEN_EXCHANGE_CONFIG])
with pytest.raises(BackendCredentialUnavailableError):
await provider.credential_for("finance-domain")
def _mock_keycloak_transport(
*, exchanged_token: str = "exchanged-access-token", expires_in: int = 60
) -> httpx.MockTransport:
calls: list[dict[str, str]] = []
def handle(request: httpx.Request) -> httpx.Response:
from urllib.parse import parse_qsl
body = dict(parse_qsl(request.content.decode()))
calls.append(body)
if body["grant_type"] == "client_credentials":
return httpx.Response(200, json={"access_token": "subject-token", "expires_in": 300})
assert body["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
assert body["subject_token"] == "subject-token"
assert body["audience"] == "finance-domain-service"
assert body["scope"] == "aud-finance-domain-service"
return httpx.Response(200, json={"access_token": exchanged_token, "expires_in": expires_in})
transport = httpx.MockTransport(handle)
transport.calls = calls # type: ignore[attr-defined]
return transport
async def test_token_exchange_produces_a_token_scoped_to_the_target_backend() -> None:
transport = _mock_keycloak_transport()
exchange_client = TokenExchangeClient(
"gateway-exchange-client",
"gateway-exchange-secret",
http_client=httpx.AsyncClient(transport=transport),
)
provider = BackendCredentialProvider(
[
BackendConfig(
backend="sales-domain",
url="http://127.0.0.1:8100/mcp",
audience="sales-domain-service",
credential_mode="token_exchange",
token_exchange_endpoint="https://keycloak.example/realms/emcp/token",
)
]
[_TOKEN_EXCHANGE_CONFIG], token_exchange_client=exchange_client
)
with pytest.raises(NotImplementedError):
provider.credential_for("sales-domain")
credential = await provider.credential_for("finance-domain")
assert credential.token == "exchanged-access-token"
assert credential.audience == "finance-domain-service"
await exchange_client.aclose()
async def test_token_exchange_result_is_cached_until_near_expiry() -> None:
transport = _mock_keycloak_transport(expires_in=300)
exchange_client = TokenExchangeClient(
"gateway-exchange-client",
"gateway-exchange-secret",
http_client=httpx.AsyncClient(transport=transport),
)
provider = BackendCredentialProvider(
[_TOKEN_EXCHANGE_CONFIG], token_exchange_client=exchange_client
)
first = await provider.credential_for("finance-domain")
second = await provider.credential_for("finance-domain")
assert first.token == second.token == "exchanged-access-token"
assert len(transport.calls) == 2 # type: ignore[attr-defined] # client_credentials + exchange, once
await exchange_client.aclose()
async def test_token_exchange_failure_fails_closed() -> None:
def handle(request: httpx.Request) -> httpx.Response:
return httpx.Response(400, json={"error": "invalid_request"})
exchange_client = TokenExchangeClient(
"gateway-exchange-client",
"gateway-exchange-secret",
http_client=httpx.AsyncClient(transport=httpx.MockTransport(handle)),
)
provider = BackendCredentialProvider(
[_TOKEN_EXCHANGE_CONFIG], token_exchange_client=exchange_client
)
with pytest.raises(BackendCredentialUnavailableError):
await provider.credential_for("finance-domain")
await exchange_client.aclose()

View File

@@ -49,30 +49,32 @@ def router(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CapabilityRouter:
return CapabilityRouter(store, backend_credentials)
def test_sales_capability_routes_to_sales_backend_not_finance(router: CapabilityRouter) -> None:
async def test_sales_capability_routes_to_sales_backend_not_finance(
router: CapabilityRouter,
) -> None:
"""README.md §6.7 EP-06-T01 acceptance: a Sales capability must reach the
Sales backend, never Finance."""
route = router.route("sales.customer.get")
route = await router.route("sales.customer.get")
assert route.backend_id == "sales-domain"
assert route.url == "http://127.0.0.1:8100/mcp"
assert route.credential_token == "sales-secret"
def test_finance_capability_routes_to_finance_backend(router: CapabilityRouter) -> None:
route = router.route("finance.payment.execute")
async def test_finance_capability_routes_to_finance_backend(router: CapabilityRouter) -> None:
route = await router.route("finance.payment.execute")
assert route.backend_id == "finance-domain"
assert route.url == "http://127.0.0.1:8101/mcp"
assert route.credential_token == "finance-secret"
def test_unknown_capability_is_unroutable(router: CapabilityRouter) -> None:
async def test_unknown_capability_is_unroutable(router: CapabilityRouter) -> None:
with pytest.raises(UnroutableCapabilityError):
router.route("sales.does.not.exist")
await router.route("sales.does.not.exist")
def test_deprecated_capability_is_unroutable(
async def test_deprecated_capability_is_unroutable(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Only ACTIVE capabilities are routable (README.md §6.6) -- a capability
@@ -95,7 +97,7 @@ def test_deprecated_capability_is_unroutable(
router = CapabilityRouter(store, backend_credentials)
with pytest.raises(UnroutableCapabilityError):
router.route("sales.customer.get")
await router.route("sales.customer.get")
def test_distinct_backends_for_sales_only_entitlement_excludes_finance(

View File

@@ -0,0 +1,90 @@
"""Offering Filter (EP-10-T01, README.md §6.11).
Real Registry seeded from the actual config/capabilities/ manifests, same
pattern as tests/unit/test_registry_seed_manifests.py / test_fabric_router.py.
"""
from __future__ import annotations
from pathlib import Path
from emcp_bus.offering_filter.service import ConsentContext, OfferingFilterService
from emcp_bus.registry.seed import load_all_capability_manifests, publish_all
from emcp_bus.registry.store import RegistryStore
REPO_ROOT = Path(__file__).parents[2]
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
def _service(tmp_path: Path) -> OfferingFilterService:
store = RegistryStore(tmp_path / "registry.db")
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
return OfferingFilterService(store)
def test_filtered_offerings_never_exceeds_the_allowed_tools_set(tmp_path: Path) -> None:
service = _service(tmp_path)
allowed_tools = frozenset({"sales.customer.get", "sales.order.get"})
offerings = service.filtered_offerings(allowed_tools=allowed_tools)
assert {m.name for m in offerings} == allowed_tools
def test_an_item_outside_entitlement_never_appears_regardless_of_catalog_size(
tmp_path: Path,
) -> None:
"""README.md §6.11's core invariant, the single-case version of what
test_offering_filter_invariants.py checks with 1000+ generated cases."""
service = _service(tmp_path)
allowed_tools = frozenset({"sales.customer.get"}) # finance.* deliberately excluded
offerings = service.filtered_offerings(allowed_tools=allowed_tools)
assert all(not m.name.startswith("finance.") for m in offerings)
def test_empty_entitlement_yields_no_offerings(tmp_path: Path) -> None:
service = _service(tmp_path)
offerings = service.filtered_offerings(allowed_tools=frozenset())
assert offerings == []
def test_restricted_data_classification_excluded_without_consent(tmp_path: Path) -> None:
"""finance.payment.create/execute are data_classification: [confidential, restricted]
(config/capabilities/finance/payment-*.yaml) -- excluded by default even
when the client is entitled to them, absent explicit consent."""
service = _service(tmp_path)
allowed_tools = frozenset({"finance.payment.create", "finance.invoice.get"})
offerings = service.filtered_offerings(allowed_tools=allowed_tools)
names = {m.name for m in offerings}
assert "finance.payment.create" not in names
assert "finance.invoice.get" in names # confidential only, not restricted
def test_restricted_data_classification_included_with_consent(tmp_path: Path) -> None:
service = _service(tmp_path)
allowed_tools = frozenset({"finance.payment.create"})
offerings = service.filtered_offerings(
allowed_tools=allowed_tools, context=ConsentContext(allow_restricted_data=True)
)
assert {m.name for m in offerings} == {"finance.payment.create"}
def test_a_tool_not_in_the_active_registry_is_never_offered_even_if_entitled(
tmp_path: Path,
) -> None:
"""MaximumEntitlement can name a tool the Registry doesn't know about
(or has since deprecated) -- FilteredOfferings ⊆ ActiveCatalog too."""
service = _service(tmp_path)
allowed_tools = frozenset({"sales.customer.get", "sales.does.not.exist"})
offerings = service.filtered_offerings(allowed_tools=allowed_tools)
assert {m.name for m in offerings} == {"sales.customer.get"}

View File

@@ -0,0 +1,104 @@
"""Property-based tests for the Offering Filter's subset invariant (EP-10-T02,
README.md §7.3):
FilteredOfferings ⊆ MaximumEntitlement ⊆ GlobalCatalog
This module tests the two sub-invariants `OfferingFilterService` actually
enforces -- `FilteredOfferings ⊆ ActiveCatalog` and
`FilteredOfferings ⊆ MaximumEntitlement` -- against 1000+ randomly generated
(catalog, entitlement) pairs. `MaximumEntitlement ⊆ GlobalCatalog` is a
property of how client profiles are *authored* (EP-04, against real registry
names), not something the filter can or should assume about its own inputs
(a profile YAML can legitimately reference a tool the Registry has since
deprecated -- README.md §6.6 lifecycle -- and the filter's job is precisely
to drop that gracefully, see `test_offering_filter.py`'s
`test_a_tool_not_in_the_active_registry_is_never_offered_even_if_entitled`,
which exercises the real SQLite-backed `RegistryStore`).
Uses an in-memory `ActiveCapabilitySource` stand-in (real `CapabilityManifest`
instances, no SQLite) so 1000+ generated cases run in-process -- this is
testing `OfferingFilterService`'s own set arithmetic, not Registry
persistence (already covered by tests/unit/test_registry.py and
test_offering_filter.py's real-`RegistryStore` tests).
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from emcp_bus.common.models import RiskTier
from emcp_bus.offering_filter.service import OfferingFilterService
from emcp_bus.registry.models import (
ApprovalPolicy,
BackendRef,
CapabilityManifest,
LifecycleInfo,
LifecycleStatus,
)
_DOMAINS = ("sales", "finance", "hr")
_UNIVERSE = tuple(f"{domain}.capability{i}" for domain in _DOMAINS for i in range(8))
"""24 synthetic capability names spanning 3 domains -- large enough for
Hypothesis to explore meaningfully varied (catalog, entitlement) overlaps."""
def _manifest(name: str) -> CapabilityManifest:
domain = name.split(".", 1)[0]
return CapabilityManifest(
name=name,
version="1.0.0",
domain=domain,
owner=f"{domain}-team",
risk_tier=RiskTier.R1,
side_effects=False,
idempotent=True,
entitlements=["Any.Profile"],
backend=BackendRef(type="mcp", service=f"{domain}-domain"),
approval=ApprovalPolicy(),
lifecycle=LifecycleInfo(status=LifecycleStatus.ACTIVE),
)
class _InMemoryActiveCapabilitySource:
def __init__(self, names: frozenset[str]) -> None:
self._manifests = [_manifest(name) for name in names]
def list_active(self) -> list[CapabilityManifest]:
return self._manifests
@given(
active_names=st.frozensets(st.sampled_from(_UNIVERSE)),
allowed_tools=st.frozensets(st.sampled_from(_UNIVERSE)),
)
@settings(max_examples=1000)
def test_filtered_offerings_is_always_a_subset_of_both_catalog_and_entitlement(
active_names: frozenset[str], allowed_tools: frozenset[str]
) -> None:
service = OfferingFilterService(_InMemoryActiveCapabilitySource(active_names))
offerings = service.filtered_offerings(allowed_tools=allowed_tools)
offering_names = {m.name for m in offerings}
assert offering_names <= active_names, "FilteredOfferings ⊆ ActiveCatalog violated"
assert offering_names <= allowed_tools, "FilteredOfferings ⊆ MaximumEntitlement violated"
@given(
active_names=st.frozensets(st.sampled_from(_UNIVERSE), min_size=1),
excluded_name=st.sampled_from(_UNIVERSE),
)
@settings(max_examples=1000)
def test_an_item_outside_entitlement_never_appears_no_matter_the_catalog(
active_names: frozenset[str], excluded_name: str
) -> None:
"""The concrete failure mode README.md §6.11 rules out: an item present
in the catalog but absent from MaximumEntitlement must never leak into
FilteredOfferings, regardless of what else is in the catalog."""
allowed_tools = frozenset(active_names) - {excluded_name}
service = OfferingFilterService(_InMemoryActiveCapabilitySource(active_names))
offerings = service.filtered_offerings(allowed_tools=allowed_tools)
assert excluded_name not in {m.name for m in offerings}

View File

@@ -0,0 +1,48 @@
"""DecisionContext contract (EP-11-T01)."""
from __future__ import annotations
from datetime import datetime
import pytest
from pydantic import ValidationError
from emcp_bus.common.models import RiskTier
from emcp_bus.orchestrator.decision_context import DecisionContext, RuntimeContext
from emcp_bus.profile_intelligence.models import ProfileView
def _profile() -> ProfileView:
return ProfileView(
subject_ref="subject:abc123", profile_version="v1", expires_at=datetime(2026, 1, 1)
)
def test_decision_context_requires_entitlement_version() -> None:
with pytest.raises(ValidationError):
DecisionContext( # type: ignore[call-arg]
client_id="sales-read-agent",
risk_ceiling=RiskTier.R1,
allowed_tools=frozenset({"sales.customer.get"}),
profile=_profile(),
filtered_offerings=[],
runtime_context=RuntimeContext(channel="app"),
)
def test_decision_context_is_serializable_to_json() -> None:
context = DecisionContext(
client_id="sales-read-agent",
entitlement_version="ent-1",
risk_ceiling=RiskTier.R1,
allowed_tools=frozenset({"sales.customer.get"}),
profile=_profile(),
filtered_offerings=[],
runtime_context=RuntimeContext(channel="app"),
)
dumped = context.model_dump_json()
restored = DecisionContext.model_validate_json(dumped)
assert restored.entitlement_version == "ent-1"
assert restored.allowed_tools == frozenset({"sales.customer.get"})

View File

@@ -0,0 +1,129 @@
"""Reference NBADecisionModel (EP-11-T03, README.md §6.13)."""
from __future__ import annotations
from datetime import datetime
from emcp_bus.common.models import RiskTier
from emcp_bus.orchestrator.decision_context import DecisionContext, RuntimeContext
from emcp_bus.orchestrator.decisioning import RuleBasedNBADecisionModel
from emcp_bus.orchestrator.nba_model import NBADecision, NBADecisionModel
from emcp_bus.profile_intelligence.models import ProfileView
from emcp_bus.registry.models import (
ApprovalPolicy,
BackendRef,
CapabilityManifest,
LifecycleInfo,
LifecycleStatus,
)
def _manifest(name: str, risk_tier: RiskTier = RiskTier.R1) -> CapabilityManifest:
return CapabilityManifest(
name=name,
version="1.0.0",
domain=name.split(".", 1)[0],
owner="sales-team",
risk_tier=risk_tier,
side_effects=False,
idempotent=True,
entitlements=["Sales.Read"],
backend=BackendRef(type="mcp", service="sales-domain"),
approval=ApprovalPolicy(),
lifecycle=LifecycleInfo(status=LifecycleStatus.ACTIVE),
)
def _context(offerings: list[CapabilityManifest]) -> DecisionContext:
profile = ProfileView(
subject_ref="subject:abc123",
profile_version="v1",
reason_codes=["LOW_RECENCY"],
expires_at=datetime(2026, 1, 1),
)
return DecisionContext(
client_id="sales-read-agent",
entitlement_version="ent-1",
risk_ceiling=RiskTier.R2,
allowed_tools=frozenset(m.name for m in offerings),
profile=profile,
filtered_offerings=offerings,
runtime_context=RuntimeContext(channel="app"),
)
def test_decide_returns_none_when_no_offerings_survive() -> None:
model = RuleBasedNBADecisionModel()
assert model.decide(_context([])) is None
def test_decide_always_includes_reason_codes_policy_context_and_expiry() -> None:
"""EP-11-T03 acceptance criterion: every NBA/NBO carries reasonCodes,
policyContext and expiresAt (README.md §6.13)."""
model = RuleBasedNBADecisionModel()
context = _context([_manifest("sales.customer.get")])
nba = model.decide(context)
assert nba is not None
assert "ELIGIBLE" in nba.reason_codes
assert "LOW_RECENCY" in nba.reason_codes
assert nba.policy_context["entitlementVersion"] == "ent-1"
assert nba.expires_at is not None
def test_decide_prefers_the_lowest_risk_tier_offering() -> None:
model = RuleBasedNBADecisionModel()
context = _context(
[_manifest("sales.quote.create", RiskTier.R2), _manifest("sales.customer.get", RiskTier.R1)]
)
nba = model.decide(context)
assert nba is not None
assert nba.action == "sales.customer.get"
def test_decide_never_selects_an_offering_outside_filtered_offerings() -> None:
"""The structural guarantee EP-11-T03's second acceptance criterion
relies on: the model only ever ranks what DecisionContext already
carries -- there is no code path here that looks anywhere else."""
model = RuleBasedNBADecisionModel()
context = _context([_manifest("sales.customer.get")])
nba = model.decide(context)
assert nba is not None
assert nba.action in {o.name for o in context.filtered_offerings}
def test_a_swapped_decision_model_satisfies_the_same_protocol() -> None:
"""EP-11-T03's pluggability criterion: any NBADecisionModel
implementation works with the exact same DecisionContext/NBADecision
contract -- no EP-03/EP-04/EP-05 change required to swap one in."""
class AlwaysFirstOfferingModel:
def decide(self, context: DecisionContext) -> NBADecision | None:
if not context.filtered_offerings:
return None
offering = context.filtered_offerings[0]
return NBADecision(
decision_id="dec-fixed",
action=offering.name,
capability_version=offering.version,
subject_ref=context.profile.subject_ref,
channel=context.runtime_context.channel,
agent="test-agent",
reason_codes=["ELIGIBLE"],
policy_context={"entitlementVersion": context.entitlement_version},
expires_at=datetime(2026, 1, 1),
)
model: NBADecisionModel = AlwaysFirstOfferingModel()
context = _context([_manifest("sales.customer.get")])
nba = model.decide(context)
assert nba is not None
assert nba.action == "sales.customer.get"

View File

@@ -0,0 +1,114 @@
"""Recalculation / safe degradation (EP-11-T04, README.md §10.1, Axiom 12, P9)."""
from __future__ import annotations
from datetime import datetime
from emcp_bus.audit.sink import InMemoryAuditSink
from emcp_bus.common.models import RiskTier
from emcp_bus.orchestrator.decision_context import DecisionContext, RuntimeContext
from emcp_bus.orchestrator.decisioning import RuleBasedNBADecisionModel
from emcp_bus.orchestrator.fallback import DenialOutcome, handle_denial
from emcp_bus.profile_intelligence.models import ProfileView
from emcp_bus.registry.models import (
ApprovalPolicy,
BackendRef,
CapabilityManifest,
LifecycleInfo,
LifecycleStatus,
)
def _manifest(name: str) -> CapabilityManifest:
return CapabilityManifest(
name=name,
version="1.0.0",
domain=name.split(".", 1)[0],
owner="sales-team",
risk_tier=RiskTier.R1,
side_effects=False,
idempotent=True,
entitlements=["Sales.Read"],
backend=BackendRef(type="mcp", service="sales-domain"),
approval=ApprovalPolicy(),
lifecycle=LifecycleInfo(status=LifecycleStatus.ACTIVE),
)
def _context(offerings: list[CapabilityManifest]) -> DecisionContext:
profile = ProfileView(
subject_ref="subject:abc123", profile_version="v1", expires_at=datetime(2026, 1, 1)
)
return DecisionContext(
client_id="sales-read-agent",
entitlement_version="ent-1",
risk_ceiling=RiskTier.R2,
allowed_tools=frozenset(m.name for m in offerings),
profile=profile,
filtered_offerings=offerings,
runtime_context=RuntimeContext(channel="app"),
)
def test_handle_denial_recalculates_over_the_remaining_offerings() -> None:
context = _context([_manifest("sales.customer.get"), _manifest("sales.order.get")])
result = handle_denial(
context, denied_action="sales.customer.get", decision_model=RuleBasedNBADecisionModel()
)
assert result.outcome is DenialOutcome.RECALCULATED
assert result.nba is not None
assert result.nba.action == "sales.order.get"
def test_handle_denial_ends_the_journey_when_no_offering_remains() -> None:
context = _context([_manifest("sales.customer.get")])
result = handle_denial(
context, denied_action="sales.customer.get", decision_model=RuleBasedNBADecisionModel()
)
assert result.outcome is DenialOutcome.ENDED
assert result.nba is None
def test_handle_denial_never_reselects_the_denied_action() -> None:
context = _context([_manifest("sales.customer.get"), _manifest("sales.order.get")])
result = handle_denial(
context, denied_action="sales.customer.get", decision_model=RuleBasedNBADecisionModel()
)
assert result.nba is not None
assert result.nba.action != "sales.customer.get"
def test_handle_denial_emits_an_audited_event_on_recalculation() -> None:
sink = InMemoryAuditSink()
context = _context([_manifest("sales.customer.get"), _manifest("sales.order.get")])
handle_denial(
context,
denied_action="sales.customer.get",
decision_model=RuleBasedNBADecisionModel(),
audit_sink=sink,
)
assert len(sink.events) == 1
assert sink.events[0].event_type == "nba_recalculated"
def test_handle_denial_emits_an_audited_event_on_ending() -> None:
sink = InMemoryAuditSink()
context = _context([_manifest("sales.customer.get")])
handle_denial(
context,
denied_action="sales.customer.get",
decision_model=RuleBasedNBADecisionModel(),
audit_sink=sink,
)
assert len(sink.events) == 1
assert sink.events[0].event_type == "journey_ended_after_denial"

View File

@@ -0,0 +1,309 @@
"""Journey StateGraph (EP-11-T02/T04/T05), against real OPA + the real seed
config/ + a real SQLite checkpointer -- not hand-built fixtures, matching
the rest of this RI's "real dependencies over mocks" testing style.
"""
from __future__ import annotations
import asyncio
import os
import shutil
import socket
import subprocess
import tempfile
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
import pytest
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command
from emcp_bus.common.config import load_yaml_model, load_yaml_models
from emcp_bus.entitlement.manager import EntitlementManager
from emcp_bus.entitlement.models import ClientProfile
from emcp_bus.identity.models import ClientRegistration
from emcp_bus.offering_filter.service import ConsentContext, OfferingFilterService
from emcp_bus.orchestrator.decision_context import DecisionContext
from emcp_bus.orchestrator.decisioning import DEFAULT_AGENT, RuleBasedNBADecisionModel
from emcp_bus.orchestrator.graph import build_journey_graph
from emcp_bus.orchestrator.nba_model import NBADecision
from emcp_bus.pdp.client import PDPClient, compute_policy_version
from emcp_bus.profile_intelligence.rule_based_provider import RuleBasedProfileProvider
from emcp_bus.registry.seed import load_all_capability_manifests, publish_all
from emcp_bus.registry.store import RegistryStore
REPO_ROOT = Path(__file__).parents[2]
POLICIES_DIR = REPO_ROOT / "config" / "policies"
PROFILES_DIR = REPO_ROOT / "config" / "clients" / "profiles"
CLIENTS_DIR = REPO_ROOT / "config" / "clients"
CAPABILITIES_DIR = REPO_ROOT / "config" / "capabilities"
SALES_READ_CLIENT_ID = "sales-read-agent"
FINANCE_PAYMENTS_CLIENT_ID = "finance-payments-agent"
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
@pytest.fixture
async def entitlement_manager() -> AsyncIterator[EntitlementManager]:
opa_binary = shutil.which(os.environ.get("OPA_BINARY", "opa"))
if opa_binary is None:
pytest.skip("opa binary not found on PATH (set OPA_BINARY or install it)")
port = _free_port()
process = subprocess.Popen(
[opa_binary, "run", "--server", "--addr", f"127.0.0.1:{port}", str(POLICIES_DIR)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
pdp = PDPClient(
base_url=f"http://127.0.0.1:{port}", policy_version=compute_policy_version(POLICIES_DIR)
)
try:
for _ in range(100):
try:
await pdp._http.get("/health")
break
except Exception: # noqa: BLE001
await asyncio.sleep(0.05)
else:
raise RuntimeError("opa server did not become healthy in time")
manager = EntitlementManager(pdp)
manager.load_registrations(
[
load_yaml_model(path, ClientRegistration)
for path in sorted(CLIENTS_DIR.glob("*.yaml"))
]
)
manager.load_profiles(load_yaml_models(PROFILES_DIR, ClientProfile))
await manager.sync_profiles_to_pdp()
yield manager
finally:
await pdp.aclose()
process.terminate()
process.wait(timeout=5)
@pytest.fixture
def offering_filter(tmp_path: Path) -> OfferingFilterService:
store = RegistryStore(tmp_path / "registry.db")
publish_all(store, load_all_capability_manifests(CAPABILITIES_DIR))
return OfferingFilterService(store)
@pytest.fixture
def sqlite_saver() -> Iterator[SqliteSaver]:
with (
tempfile.TemporaryDirectory() as tmp_dir,
SqliteSaver.from_conn_string(str(Path(tmp_dir) / "checkpoints.db")) as saver,
):
yield saver
class _PreferPaymentExecuteModel:
"""A second `NBADecisionModel` (demonstrating EP-11-T03's pluggability):
unlike `RuleBasedNBADecisionModel` (least-friction-first), this one
prefers `finance.payment.execute` when it survives EP-10's filter --
used here purely to reach the approval-required branch deterministically
(`RuleBasedNBADecisionModel` would pick the R1 `invoice.get` instead,
which never requires approval)."""
def decide(self, context: DecisionContext) -> NBADecision | None:
offerings_by_name = {o.name: o for o in context.filtered_offerings}
offering = offerings_by_name.get("finance.payment.execute") or next(
iter(context.filtered_offerings), None
)
if offering is None:
return None
return NBADecision(
decision_id="dec-test-fixed",
action=offering.name,
capability_version=offering.version,
subject_ref=context.profile.subject_ref,
channel=context.runtime_context.channel,
agent=DEFAULT_AGENT,
reason_codes=["ELIGIBLE"],
policy_context={"entitlementVersion": context.entitlement_version},
expires_at=context.resolved_at,
)
async def test_journey_runs_end_to_end_when_no_approval_is_needed(
entitlement_manager: EntitlementManager,
offering_filter: OfferingFilterService,
sqlite_saver: SqliteSaver,
) -> None:
graph = build_journey_graph(
profile_provider=RuleBasedProfileProvider(),
entitlement_manager=entitlement_manager,
offering_filter=offering_filter,
decision_model=RuleBasedNBADecisionModel(),
checkpointer=sqlite_saver,
)
config: RunnableConfig = {"configurable": {"thread_id": "journey-1"}}
result = graph.invoke(
{"client_id": SALES_READ_CLIENT_ID, "raw_subject_id": "subject:test-1", "channel": "app"},
config,
)
assert result["nba"] is not None
assert result["nba"].action.startswith("sales.")
assert result["approval_status"] == "not_required"
assert "__interrupt__" not in result
async def test_journey_pauses_at_approval_gate_for_a_high_risk_nba(
entitlement_manager: EntitlementManager,
offering_filter: OfferingFilterService,
sqlite_saver: SqliteSaver,
) -> None:
"""finance.payment.execute (R3) requires approval -- README.md §26. With
consent for restricted data (so it survives EP-10's filter) and a
Finance.Payments client, the journey graph must pause at approval_gate,
never silently proceeding as if approved."""
graph = build_journey_graph(
profile_provider=RuleBasedProfileProvider(),
entitlement_manager=entitlement_manager,
offering_filter=offering_filter,
decision_model=_PreferPaymentExecuteModel(),
checkpointer=sqlite_saver,
consent_context=ConsentContext(allow_restricted_data=True),
)
config: RunnableConfig = {"configurable": {"thread_id": "journey-2"}}
result = graph.invoke(
{
"client_id": FINANCE_PAYMENTS_CLIENT_ID,
"raw_subject_id": "subject:test-2",
"channel": "app",
},
config,
)
assert "__interrupt__" in result
assert "approval_status" not in result # not yet decided -- no implicit ALLOW
state = graph.get_state(config)
assert state.next == ("approval_gate",)
async def test_resuming_an_approval_gate_does_not_reprocess_earlier_nodes(
entitlement_manager: EntitlementManager,
offering_filter: OfferingFilterService,
sqlite_saver: SqliteSaver,
) -> None:
"""EP-11-T02's checkpoint-resume acceptance criterion: resolve_profile/
resolve_entitlement/filter_offerings/decide_nba must not re-run when the
graph resumes from a paused approval_gate."""
profile_calls = 0
real_provider = RuleBasedProfileProvider()
class CountingProfileProvider:
def resolve(self, subject_ref: str, *, as_of: object = None) -> object:
nonlocal profile_calls
profile_calls += 1
return real_provider.resolve(subject_ref)
graph = build_journey_graph(
profile_provider=CountingProfileProvider(), # type: ignore[arg-type]
entitlement_manager=entitlement_manager,
offering_filter=offering_filter,
decision_model=_PreferPaymentExecuteModel(),
checkpointer=sqlite_saver,
consent_context=ConsentContext(allow_restricted_data=True),
)
config: RunnableConfig = {"configurable": {"thread_id": "journey-3"}}
graph.invoke(
{
"client_id": FINANCE_PAYMENTS_CLIENT_ID,
"raw_subject_id": "subject:test-3",
"channel": "app",
},
config,
)
assert profile_calls == 1
# Simulate "the process restarted": a fresh CompiledStateGraph, same
# checkpointer/thread_id -- the paused state must already be there.
resumed_graph = build_journey_graph(
profile_provider=CountingProfileProvider(), # type: ignore[arg-type]
entitlement_manager=entitlement_manager,
offering_filter=offering_filter,
decision_model=_PreferPaymentExecuteModel(),
checkpointer=sqlite_saver,
consent_context=ConsentContext(allow_restricted_data=True),
)
result = resumed_graph.invoke(Command(resume="approved"), config)
assert profile_calls == 1 # unchanged -- resolve_profile did not re-run
assert result["approval_status"] == "approved"
async def test_resuming_with_a_non_approved_decision_denies(
entitlement_manager: EntitlementManager,
offering_filter: OfferingFilterService,
sqlite_saver: SqliteSaver,
) -> None:
graph = build_journey_graph(
profile_provider=RuleBasedProfileProvider(),
entitlement_manager=entitlement_manager,
offering_filter=offering_filter,
decision_model=_PreferPaymentExecuteModel(),
checkpointer=sqlite_saver,
consent_context=ConsentContext(allow_restricted_data=True),
)
config: RunnableConfig = {"configurable": {"thread_id": "journey-4"}}
graph.invoke(
{
"client_id": FINANCE_PAYMENTS_CLIENT_ID,
"raw_subject_id": "subject:test-4",
"channel": "app",
},
config,
)
result = graph.invoke(Command(resume="denied"), config)
assert result["approval_status"] == "denied"
async def test_denial_reentry_recalculates_nba_excluding_the_denied_action(
entitlement_manager: EntitlementManager,
offering_filter: OfferingFilterService,
sqlite_saver: SqliteSaver,
) -> None:
"""EP-11-T04: after a real Gateway DENY for the first NBA, re-invoking
the graph with `denied_action` set recomputes NBA over the remaining
offerings -- never by calling any backend directly (fallback.py has no
such code path at all)."""
graph = build_journey_graph(
profile_provider=RuleBasedProfileProvider(),
entitlement_manager=entitlement_manager,
offering_filter=offering_filter,
decision_model=RuleBasedNBADecisionModel(),
checkpointer=sqlite_saver,
)
config: RunnableConfig = {"configurable": {"thread_id": "journey-5"}}
first = graph.invoke(
{"client_id": SALES_READ_CLIENT_ID, "raw_subject_id": "subject:test-5", "channel": "app"},
config,
)
first_action = first["nba"].action
second = graph.invoke({"denied_action": first_action}, config)
assert second["denial_outcome"] in {"recalculated", "ended"}
if second["denial_outcome"] == "recalculated":
assert second["nba"] is not None
assert second["nba"].action != first_action
else:
assert second["nba"] is None

View File

@@ -0,0 +1,70 @@
"""Human-in-the-loop gate (EP-11-T05). The actual pause/resume behavior is
covered end to end through the real graph in test_orchestrator_graph.py;
this file covers `approval_gate_node`'s two branches that never interrupt.
"""
from __future__ import annotations
from datetime import datetime
from emcp_bus.common.models import RiskTier
from emcp_bus.orchestrator.hitl_node import approval_gate_node, requires_approval
from emcp_bus.orchestrator.nba_model import NBADecision
from emcp_bus.registry.models import (
ApprovalPolicy,
BackendRef,
CapabilityManifest,
LifecycleInfo,
LifecycleStatus,
)
def _manifest(name: str, *, approval_required: bool) -> CapabilityManifest:
return CapabilityManifest(
name=name,
version="1.0.0",
domain=name.split(".", 1)[0],
owner="finance-team",
risk_tier=RiskTier.R3 if approval_required else RiskTier.R1,
side_effects=True,
idempotent=False,
entitlements=["Finance.Payments"],
backend=BackendRef(type="mcp", service="finance-domain"),
approval=ApprovalPolicy(required=approval_required),
lifecycle=LifecycleInfo(status=LifecycleStatus.ACTIVE),
)
def test_requires_approval_true_for_a_flagged_capability() -> None:
offerings = [_manifest("finance.payment.execute", approval_required=True)]
assert requires_approval(offerings, "finance.payment.execute") is True
def test_requires_approval_false_for_an_unflagged_capability() -> None:
offerings = [_manifest("sales.customer.get", approval_required=False)]
assert requires_approval(offerings, "sales.customer.get") is False
def test_approval_gate_node_skips_when_there_is_no_nba() -> None:
result = approval_gate_node({"nba": None})
assert result == {"approval_status": "not_applicable"}
def test_approval_gate_node_skips_when_approval_is_not_required() -> None:
nba = NBADecision(
decision_id="dec-1",
action="sales.customer.get",
capability_version="1.0.0",
subject_ref="subject:abc123",
channel="app",
agent="engagement-assistant",
reason_codes=["ELIGIBLE"],
policy_context={},
expires_at=datetime(2026, 1, 1),
)
result = approval_gate_node({"nba": nba, "nba_requires_approval": False})
assert result == {"approval_status": "not_required"}

View File

@@ -0,0 +1,52 @@
"""PII/secret redaction before Langfuse export (EP-13-T03, README.md §27)."""
from __future__ import annotations
from emcp_bus.common.pii_redaction import redact_mapping, redact_text
def test_redact_text_masks_an_email() -> None:
result = redact_text("contact me at jane.doe@example.com please")
assert result == "contact me at [REDACTED] please"
def test_redact_text_masks_a_cpf() -> None:
assert redact_text("cpf: 123.456.789-01") == "cpf: [REDACTED]"
def test_redact_text_masks_a_phone_number() -> None:
assert redact_text("call +55 11 99999-8888") == "call [REDACTED]"
def test_redact_text_leaves_non_pii_text_unchanged() -> None:
text = "the customer wants a quote for sku-100"
assert redact_text(text) == text
def test_redact_mapping_redacts_a_sensitive_key_outright() -> None:
result = redact_mapping({"email": "jane.doe@example.com", "amount": 100})
assert result["email"] == "[REDACTED]"
assert result["amount"] == 100
def test_redact_mapping_scans_string_values_for_pii_patterns() -> None:
result = redact_mapping({"note": "reach jane.doe@example.com for details"})
assert result["note"] == "reach [REDACTED] for details"
def test_redact_mapping_recurses_into_nested_dicts() -> None:
result = redact_mapping({"customer": {"email": "jane.doe@example.com", "name": "Jane"}})
assert result["customer"]["email"] == "[REDACTED]"
assert result["customer"]["name"] == "Jane"
def test_redact_mapping_recurses_into_lists_of_dicts_and_strings() -> None:
result = redact_mapping(
{"notes": ["contact jane.doe@example.com", {"email": "other@example.com"}]}
)
assert result["notes"][0] == "contact [REDACTED]"
assert result["notes"][1]["email"] == "[REDACTED]"

View File

@@ -0,0 +1,173 @@
"""Profile Intelligence (EP-09, README.md §6.10, Axiom 11)."""
from __future__ import annotations
from datetime import datetime
import pytest
from pydantic import ValidationError
from emcp_bus.profile_intelligence.governance import (
DriftMonitor,
FeatureLineageLog,
SensitiveAttributeLeakageError,
assert_no_raw_pii,
pseudonymize_subject,
)
from emcp_bus.profile_intelligence.models import ProfileView, Segment
from emcp_bus.profile_intelligence.rule_based_provider import RuleBasedProfileProvider
def test_profile_view_requires_expires_at() -> None:
with pytest.raises(ValidationError):
ProfileView(subject_ref="subject:abc123", profile_version="v1") # type: ignore[call-arg]
def test_profile_view_requires_profile_version() -> None:
with pytest.raises(ValidationError):
ProfileView( # type: ignore[call-arg]
subject_ref="subject:abc123", expires_at=datetime(2026, 1, 1)
)
def test_segment_score_must_be_within_unit_interval() -> None:
with pytest.raises(ValidationError):
Segment(id="low-engagement", score=1.5)
# --- EP-09-T02: rule-based provider -----------------------------------------
def test_rule_based_provider_is_deterministic_for_same_subject_and_window() -> None:
provider = RuleBasedProfileProvider()
as_of = datetime(2026, 9, 14)
first = provider.resolve("subject:7f31c2", as_of=as_of)
second = provider.resolve("subject:7f31c2", as_of=as_of)
assert first.profile_version == second.profile_version
assert first.segments == second.segments
assert first.attributes == second.attributes
def test_rule_based_provider_differs_across_subjects() -> None:
provider = RuleBasedProfileProvider()
as_of = datetime(2026, 9, 14)
first = provider.resolve("subject:aaaaaa", as_of=as_of)
second = provider.resolve("subject:bbbbbb", as_of=as_of)
assert first.profile_version != second.profile_version
def test_rule_based_provider_differs_across_time_windows() -> None:
provider = RuleBasedProfileProvider()
week_one = provider.resolve("subject:7f31c2", as_of=datetime(2026, 1, 5))
week_ten = provider.resolve("subject:7f31c2", as_of=datetime(2026, 3, 9))
assert week_one.profile_version != week_ten.profile_version
def test_rule_based_provider_never_produces_a_raw_subject_ref() -> None:
"""The provider itself never invents a subject_ref -- it only echoes
back whatever it was given, so pseudonymization is the caller's job
(governance.pseudonymize_subject) applied *before* calling resolve()."""
provider = RuleBasedProfileProvider()
result = provider.resolve(pseudonymize_subject("customer-raw-id-001"))
assert_no_raw_pii(result) # must not raise
# --- EP-09-T03: governance ---------------------------------------------------
def test_pseudonymize_subject_is_deterministic() -> None:
assert pseudonymize_subject("raw-id") == pseudonymize_subject("raw-id")
def test_pseudonymize_subject_differs_for_different_inputs() -> None:
assert pseudonymize_subject("raw-id-1") != pseudonymize_subject("raw-id-2")
def test_assert_no_raw_pii_rejects_non_pseudonymized_subject_ref() -> None:
profile = ProfileView(
subject_ref="customer-001", # a raw id, not "subject:<hash>"
profile_version="v1",
expires_at=datetime(2026, 1, 1),
)
with pytest.raises(SensitiveAttributeLeakageError):
assert_no_raw_pii(profile)
def test_assert_no_raw_pii_rejects_pii_looking_attribute_key() -> None:
profile = ProfileView(
subject_ref="subject:abc123",
profile_version="v1",
attributes={"email": "someone@example.com"},
expires_at=datetime(2026, 1, 1),
)
with pytest.raises(SensitiveAttributeLeakageError):
assert_no_raw_pii(profile)
def test_assert_no_raw_pii_accepts_a_clean_profile_view() -> None:
profile = ProfileView(
subject_ref="subject:abc123",
profile_version="v1",
attributes={"engagementBand": "low"},
expires_at=datetime(2026, 1, 1),
)
assert_no_raw_pii(profile) # must not raise
def test_feature_lineage_log_records_an_entry_per_resolution() -> None:
log = FeatureLineageLog()
profile = ProfileView(
subject_ref="subject:abc123", profile_version="v1", expires_at=datetime(2026, 1, 1)
)
entry = log.record(
profile,
features=("recency", "channel_affinity"),
purpose="personalization",
time_window="2026-W37",
)
assert log.records == [entry]
assert entry.subject_ref == "subject:abc123"
assert entry.features == ("recency", "channel_affinity")
def test_drift_monitor_flags_a_large_score_delta() -> None:
monitor = DriftMonitor(threshold=0.3)
profile = ProfileView(
subject_ref="subject:abc123",
profile_version="v1",
segments=[Segment(id="low-engagement", score=0.9)],
expires_at=datetime(2026, 1, 1),
)
reports = monitor.check(profile, baseline={"low-engagement": 0.2})
assert len(reports) == 1
assert reports[0].drifted is True
def test_drift_monitor_does_not_flag_a_small_score_delta() -> None:
monitor = DriftMonitor(threshold=0.3)
profile = ProfileView(
subject_ref="subject:abc123",
profile_version="v1",
segments=[Segment(id="low-engagement", score=0.5)],
expires_at=datetime(2026, 1, 1),
)
reports = monitor.check(profile, baseline={"low-engagement": 0.45})
assert len(reports) == 1
assert reports[0].drifted is False

View File

@@ -0,0 +1,66 @@
"""Prompt versioning (EP-13-T04, `could`)."""
from __future__ import annotations
from pathlib import Path
import pytest
from example_agent.prompts.store import PromptVersionStore, UnknownPromptError
REPO_ROOT = Path(__file__).parents[2]
V1_PROMPT = REPO_ROOT / "agents" / "example_agent" / "prompts" / "v1.txt"
def test_publish_creates_version_one_for_a_new_prompt() -> None:
store = PromptVersionStore()
version = store.publish("system", "hello")
assert version.version == 1
assert version.content == "hello"
def test_a_prompt_change_creates_a_new_version_without_overwriting_the_previous_one() -> None:
store = PromptVersionStore()
store.publish("system", "v1 content")
second = store.publish("system", "v2 content")
history = store.history("system")
assert [v.content for v in history] == ["v1 content", "v2 content"]
assert second.version == 2
def test_latest_returns_the_most_recently_published_version() -> None:
store = PromptVersionStore()
store.publish("system", "v1 content")
store.publish("system", "v2 content")
assert store.latest("system").content == "v2 content"
def test_publishing_identical_content_still_creates_a_new_trackable_version() -> None:
store = PromptVersionStore()
store.publish("system", "same content")
second = store.publish("system", "same content")
assert second.version == 2
assert len(store.history("system")) == 2
def test_latest_raises_for_an_unpublished_prompt_name() -> None:
store = PromptVersionStore()
with pytest.raises(UnknownPromptError):
store.latest("does-not-exist")
def test_the_seed_v1_prompt_file_can_be_published() -> None:
store = PromptVersionStore()
content = V1_PROMPT.read_text(encoding="utf-8").strip()
version = store.publish("example-agent-system", content)
assert version.content == content
assert "tools you are given" in version.content