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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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