Module adcp.decisioning.context
Request context for DecisioningPlatform method dispatch.
:class:RequestContext extends :class:ToolContext so the
existing framework's idempotency middleware, observability hooks, and
A2A executor — all of which consume ToolContext — keep working
unchanged. Adopters' Protocol method signatures take
RequestContext[TMeta] and get typed access to the resolved
account plus a typed metadata bag.
The dispatch adapter (in adcp.decisioning.dispatch) constructs a
RequestContext per request from the underlying ToolContext and
the platform's AccountStore.resolve(…) result.
Global variables
var TMeta-
Per-platform metadata generic; mirrors
TMetabut redeclared here soRequestContext[TMeta]parameterization works without importing the same TypeVar from another module (mypy treats same-name TypeVars from different modules as distinct types, which breaks downstream Protocol matching).
Classes
class AuthInfo (kind: str,
key_id: str | None = None,
principal: str | None = None,
scopes: list[str] = <factory>,
credential: Credential | None = <object object>,
agent_url: str | None = None,
operator: str | None = None,
extra: Mapping[str, Any] = <factory>)-
Expand source code
@dataclass class AuthInfo: """The verified principal authenticated for a request. Populated by the framework's signed-request verifier (:func:`adcp.signing.signed_request_verifier`) or a custom ``authenticate=`` callable wired via :func:`adcp.decisioning.serve`. Threaded onto :attr:`RequestContext.auth_info` so platform methods can read scopes, key_id, principal, etc., without parsing transport headers. **Two field families.** The flat fields (``kind`` / ``key_id`` / ``principal`` / ``scopes``) are the v6.0 surface — adopters built against the alpha pass these directly. The Tier 2 v3-identity fields (``credential`` / ``agent_url`` / ``operator`` / ``extra``) carry the typed AdCP v3 commercial identity context the :class:`adcp.decisioning.BuyerAgentRegistry` consumes. When an adopter constructs ``AuthInfo`` with only the flat fields, ``__post_init__`` synthesizes a typed bearer :class:`adcp.decisioning.Credential` from them and emits a :class:`DeprecationWarning` pointing at the adopter callsite. **Deprecation timeline:** * **4.4.0** (this release) — flat-field synthesis still works but warns. Adopter code stays runnable; the warning points at every callsite that constructs ``AuthInfo`` without an explicit ``credential=``. * **4.5.0** — synthesis is removed; flat-field-only construction stops auto-populating ``credential``, and the registry dispatch will reject the request with ``PERMISSION_DENIED``. Adopters must construct the typed credential explicitly: ``AuthInfo(credential=ApiKeyCredential(kind="api_key", key_id=...))`` or use the bundled signed-request verifier middleware. The flat fields themselves stay (they carry useful audit / log context); only the synthesis-from-flat path is on the removal track. :param kind: One of ``'signed_request'``, ``'http_sig'``, ``'bearer'``, ``'api_key'``, ``'oauth'``, ``'mtls'``, ``'derived'``. Drives the legacy → ``credential`` synthesis. :param key_id: The signing key id (``kid``) for signed-request / http_sig auth, or the API-key id for bearer auth. :param principal: The authenticated principal label — for signed-request auth this is the verified ``agent_url`` (per AdCP v3 convention). :param scopes: Granted scopes / capabilities (OAuth or per-token). :param credential: Typed v3 :class:`adcp.decisioning.Credential` — the canonical surface the registry dispatches on. When unset, ``__post_init__`` synthesizes from the legacy fields. Adopters wiring v3 auth directly should construct the credential themselves and leave the legacy fields empty. :param agent_url: Verified buyer-agent URL — populated from ``credential.agent_url`` when ``credential`` is an :class:`adcp.decisioning.HttpSigCredential`. ``None`` for bearer / OAuth / unauthenticated traffic and for ``kind="signed_request"`` constructions that don't pass a typed credential (the SDK deliberately refuses to derive ``agent_url`` from the unverified ``principal`` string — see ``__post_init__`` for the rationale). :param operator: Operator / transport-tenant label — the AdCP v3 operator binding (separate from the buyer agent). Distinct from ``ToolContext.tenant_id`` only for adopters running the AAO community proxy in front of a multi-operator deployment; most adopters leave this ``None``. :param extra: Adopter passthrough for auth-layer fields the SDK doesn't model (custom claims, MFA flags, internal session ids). """ # Sentinel used as the default value of ``credential`` so # ``__post_init__`` can distinguish "adopter didn't pass credential # at all" (default → synthesize from flat fields) from "adopter # explicitly passed credential=None" (default → leave None, # don't re-synthesize). Without this sentinel, # ``dataclasses.replace(auth, credential=None)`` — the natural # idiom for clearing a credential — would re-trigger synthesis # from the still-present flat fields, contradicting the adopter's # intent. _UNSET_CREDENTIAL: ClassVar[Any] = object() kind: str key_id: str | None = None principal: str | None = None scopes: list[str] = field(default_factory=list) # ----- Tier 2 v3-identity fields ----- credential: Credential | None = _UNSET_CREDENTIAL agent_url: str | None = None operator: str | None = None extra: Mapping[str, Any] = field(default_factory=dict) @staticmethod def _synthesize_bearer_credential( kind: str, key_id: str | None, principal: str | None, scopes: list[str], ) -> Credential | None: """Build a typed bearer :class:`Credential` from the flat fields, or return ``None`` when the flat fields don't describe a bearer credential. Signed-request kinds (``"signed_request"`` / ``"http_sig"``) intentionally never synthesize — a real :class:`HttpSigCredential` requires the ``verified_at`` timestamp from RFC 9421 verification, which only the verifier middleware has. Synthesizing one here would let any code that writes ``kind="signed_request"`` escalate bearer traffic onto the verified signed path. The verifier middleware constructs :class:`HttpSigCredential` explicitly and passes it via ``credential=``. """ from adcp.decisioning.registry import ( ApiKeyCredential, OAuthCredential, ) if kind in {"api_key", "bearer"}: if key_id: return ApiKeyCredential(kind="api_key", key_id=key_id) elif kind == "oauth": client_id = key_id or principal if client_id: return OAuthCredential( kind="oauth", client_id=client_id, scopes=tuple(scopes), ) return None @classmethod def from_verified_signer( cls, signer: Any, *, scopes: list[str] | None = None, operator: str | None = None, extra: Mapping[str, Any] | None = None, max_verified_age_s: float | None = None, now: float | None = None, ) -> AuthInfo: """Build :class:`AuthInfo` from a :class:`adcp.signing.VerifiedSigner`. The supported migration target for the AuthInfo flat-field deprecation. Verifier middleware that runs RFC 9421 verification produces a ``VerifiedSigner`` carrying the cryptographic claims (``key_id``, ``verified_at``, optional ``agent_url``); this helper projects that into a typed :class:`HttpSigCredential` + :class:`AuthInfo` ready for the commercial-identity registry dispatch. :: from adcp.signing import ( VerifyOptions, verify_request_signature, ) from adcp.decisioning import AuthInfo signer = verify_request_signature( method=request.method, url=request.url, headers=dict(request.headers), body=request.get_data(), options=VerifyOptions(...), ) ctx.metadata["adcp.auth_info"] = AuthInfo.from_verified_signer( signer, max_verified_age_s=300.0, ) The verifier MUST surface ``signer.agent_url`` for the commercial-identity registry to dispatch — without it, the framework has no key to look up. The verifier is configured with the ``agent_url`` claim shape per the AdCP v3 profile; if it's ``None`` here the verifier wasn't told to extract it and this helper raises :class:`ValueError` with a pointer to the misconfiguration. Buyers don't see this — server boot time error. :param signer: The :class:`VerifiedSigner` returned from :func:`adcp.signing.verify_request_signature`. :param scopes: Granted scopes / capabilities. The verifier doesn't extract these — adopter middleware fills in scopes derived from token introspection or per-key policy. :param operator: Operator label for AdCP v3 multi-operator deployments (AAO community proxy). Most adopters leave ``None``. :param extra: Adopter passthrough. :param max_verified_age_s: Maximum age (seconds) of ``signer.verified_at`` permitted at construction time. When set and ``now() - signer.verified_at > max_verified_age_s``, raises :class:`ValueError` — adopter caching a stale ``VerifiedSigner`` and replaying it later fails fast rather than passing a stale-but- cryptographically-valid credential into the registry dispatch. Default ``None`` keeps the v6.0-alpha behavior; production sellers SHOULD set a short window (e.g. 300s) matching the RFC 9421 nonce TTL. :param now: Override ``time.time()`` for ``max_verified_age_s`` comparisons — only useful in tests. Default ``None`` uses wall-clock. :raises ValueError: when ``signer.agent_url`` is ``None``, or when ``max_verified_age_s`` is set and ``signer.verified_at`` is older than the window. """ import time from adcp.decisioning.registry import HttpSigCredential if signer.agent_url is None: raise ValueError( "VerifiedSigner.agent_url is None — the AdCP request-signing " "verifier wasn't configured to extract the agent_url claim. " "Set ``VerifyOptions.agent_url=`` (or its source) so the " "verifier surfaces it on success. Without an agent_url, the " "BuyerAgentRegistry has no key to dispatch on." ) if max_verified_age_s is not None: current = now if now is not None else time.time() age = current - signer.verified_at if age > max_verified_age_s: raise ValueError( f"VerifiedSigner.verified_at is {age:.1f}s old, exceeds " f"max_verified_age_s={max_verified_age_s:.1f}s. The " "verifier's signature was valid at the time of verification " "but has aged out of the freshness window — typically " "indicates a cached signer being replayed. Re-verify the " "request signature instead of constructing AuthInfo from " "a stale signer." ) credential = HttpSigCredential( kind="http_sig", keyid=signer.key_id, agent_url=signer.agent_url, verified_at=signer.verified_at, ) return cls( kind="http_sig", key_id=signer.key_id, principal=signer.agent_url, scopes=list(scopes) if scopes is not None else [], credential=credential, agent_url=signer.agent_url, operator=operator, extra=extra if extra is not None else {}, ) @classmethod def _from_legacy_dict(cls, raw: Mapping[str, Any]) -> AuthInfo: """Build :class:`AuthInfo` from a legacy dict-shape metadata payload without firing the :class:`DeprecationWarning`. The framework's :func:`_extract_auth_info` translates ``ctx.metadata['adcp.auth_info']`` dicts into typed ``AuthInfo``. That translation happens once per request and the warning's stack would point into framework code — not useful for adopters. Pre-synthesize the credential and pass it via ``credential=`` so :meth:`__post_init__`'s synthesis branch is skipped along with the warning. Adopter code that constructs ``AuthInfo`` directly (in their own ``context_factory`` / auth middleware) goes through :meth:`__post_init__` and *does* see the warning, pointing at the adopter callsite — which is the actionable signal this deprecation is meant to deliver. Validates the dict shape — adopters porting JS-side middleware sometimes write ``scopes`` as a string instead of a list, or pass a raw dict where a typed :class:`Credential` is expected. Silently coercing those would mask middleware bugs that only surface when the registry dispatch later tries to use the malformed credential. Raise :class:`TypeError` with the offending field name so adopter logs surface the issue immediately at server boot / first request. """ from adcp.decisioning.registry import ( ApiKeyCredential, HttpSigCredential, OAuthCredential, ) # Type guards. Empty dict is fine — we project to a # ``kind="derived"`` AuthInfo with no credential. scopes_raw = raw.get("scopes", []) if not isinstance(scopes_raw, (list, tuple)): raise TypeError( "adcp.auth_info dict has scopes={scopes_raw!r} of type " f"{type(scopes_raw).__name__}; expected list / tuple of " "strings. Adopter middleware writing a string here (e.g. " "comma-separated scopes) needs to split before passing " "to AuthInfo.".format(scopes_raw=scopes_raw) ) credential = raw.get("credential") if credential is not None and not isinstance( credential, (ApiKeyCredential, OAuthCredential, HttpSigCredential) ): raise TypeError( "adcp.auth_info dict has credential of type " f"{type(credential).__name__}; expected an instance of " "ApiKeyCredential / OAuthCredential / HttpSigCredential. " "Construct the typed credential explicitly in your auth " "middleware — the framework can't safely build it from a " "raw dict because it can't distinguish kinds." ) extra = raw.get("extra", {}) if not isinstance(extra, Mapping): raise TypeError( f"adcp.auth_info dict has extra={extra!r} of type " f"{type(extra).__name__}; expected a Mapping." ) kind = raw.get("kind", "derived") key_id = raw.get("key_id") principal = raw.get("principal") scopes = list(scopes_raw) if credential is None: credential = cls._synthesize_bearer_credential(kind, key_id, principal, scopes) return cls( kind=kind, key_id=key_id, principal=principal, scopes=scopes, credential=credential, agent_url=raw.get("agent_url"), operator=raw.get("operator"), extra=extra, ) def __post_init__(self) -> None: """Synthesize a typed bearer ``credential`` from the flat ``kind`` / ``key_id`` / ``principal`` fields when not supplied directly, and emit a :class:`DeprecationWarning` pointing at the adopter callsite that needs to migrate. Synthesis fires for bearer kinds only; signed-request kinds require an explicit :class:`HttpSigCredential` from the verifier (see :meth:`_synthesize_bearer_credential` for rationale). ``agent_url`` is derived from a present :class:`HttpSigCredential` only — never from the ``principal`` string, since unverified principals must not appear as verified agent URLs. Synthesis is one-way: explicit ``credential=`` always wins and suppresses the warning. """ from adcp.decisioning.registry import HttpSigCredential if self.credential is AuthInfo._UNSET_CREDENTIAL: # Default — adopter didn't pass ``credential=`` at all. # Synthesize from flat fields if they describe a bearer # credential; warn so the adopter migrates. synthesized = self._synthesize_bearer_credential( self.kind, self.key_id, self.principal, self.scopes ) if synthesized is not None: self.credential = synthesized warnings.warn( "AuthInfo was constructed without an explicit " "`credential=`; the SDK synthesized " f"{type(synthesized).__name__} from the flat " "`kind` / `key_id` / `principal` fields. The " "synthesis path is deprecated and will be removed " "in adcp 4.5.0. Construct the typed credential " "explicitly, e.g. " '`AuthInfo(credential=ApiKeyCredential(kind="api_key",' " key_id=...))`. See " "docs/proposals/v3-identity-bundle-design.md for " "the v3 identity migration guide.", DeprecationWarning, stacklevel=2, ) else: self.credential = None # ELSE: adopter passed ``credential=...`` explicitly (real # credential or ``None``). Honor their value verbatim — no # synthesis, no warning. This makes # ``dataclasses.replace(auth, credential=None)`` correctly # clear the credential without re-running synthesis. if self.agent_url is None and isinstance(self.credential, HttpSigCredential): self.agent_url = self.credential.agent_urlThe verified principal authenticated for a request.
Populated by the framework's signed-request verifier (:func:
adcp.signing.signed_request_verifier) or a customauthenticate=callable wired via :func:serve(). Threaded onto :attr:RequestContext.auth_infoso platform methods can read scopes, key_id, principal, etc., without parsing transport headers.Two field families. The flat fields (
kind/key_id/principal/scopes) are the v6.0 surface — adopters built against the alpha pass these directly. The Tier 2 v3-identity fields (credential/agent_url/operator/extra) carry the typed AdCP v3 commercial identity context the :class:BuyerAgentRegistryconsumes. When an adopter constructsAuthInfowith only the flat fields,__post_init__synthesizes a typed bearer :class:adcp.decisioning.Credentialfrom them and emits a :class:DeprecationWarningpointing at the adopter callsite.Deprecation timeline:
- 4.4.0 (this release) — flat-field synthesis still works but
warns. Adopter code stays runnable; the warning points at every
callsite that constructs
AuthInfowithout an explicitcredential=. - 4.5.0 — synthesis is removed; flat-field-only construction
stops auto-populating
credential, and the registry dispatch will reject the request withPERMISSION_DENIED. Adopters must construct the typed credential explicitly:AuthInfo(credential=ApiKeyCredential(kind="api_key", key_id=...))or use the bundled signed-request verifier middleware.
The flat fields themselves stay (they carry useful audit / log context); only the synthesis-from-flat path is on the removal track.
:param kind: One of
'signed_request','http_sig','bearer','api_key','oauth','mtls','derived'. Drives the legacy →credentialsynthesis. :param key_id: The signing key id (kid) for signed-request / http_sig auth, or the API-key id for bearer auth. :param principal: The authenticated principal label — for signed-request auth this is the verifiedagent_url(per AdCP v3 convention). :param scopes: Granted scopes / capabilities (OAuth or per-token). :param credential: Typed v3 :class:adcp.decisioning.Credential— the canonical surface the registry dispatches on. When unset,__post_init__synthesizes from the legacy fields. Adopters wiring v3 auth directly should construct the credential themselves and leave the legacy fields empty. :param agent_url: Verified buyer-agent URL — populated fromcredential.agent_urlwhencredentialis an :class:HttpSigCredential.Nonefor bearer / OAuth / unauthenticated traffic and forkind="signed_request"constructions that don't pass a typed credential (the SDK deliberately refuses to deriveagent_urlfrom the unverifiedprincipalstring — see__post_init__for the rationale). :param operator: Operator / transport-tenant label — the AdCP v3 operator binding (separate from the buyer agent). Distinct fromToolContext.tenant_idonly for adopters running the AAO community proxy in front of a multi-operator deployment; most adopters leave thisNone. :param extra: Adopter passthrough for auth-layer fields the SDK doesn't model (custom claims, MFA flags, internal session ids).Static methods
def from_verified_signer(signer: Any,
*,
scopes: list[str] | None = None,
operator: str | None = None,
extra: Mapping[str, Any] | None = None,
max_verified_age_s: float | None = None,
now: float | None = None) ‑> AuthInfo-
Build :class:
AuthInfofrom a :class:VerifiedSigner.The supported migration target for the AuthInfo flat-field deprecation. Verifier middleware that runs RFC 9421 verification produces a
VerifiedSignercarrying the cryptographic claims (key_id,verified_at, optionalagent_url); this helper projects that into a typed :class:HttpSigCredential+ :class:AuthInfoready for the commercial-identity registry dispatch.::
from adcp.signing import ( VerifyOptions, verify_request_signature, ) from adcp.decisioning import AuthInfo signer = verify_request_signature( method=request.method, url=request.url, headers=dict(request.headers), body=request.get_data(), options=VerifyOptions(...), ) ctx.metadata["adcp.auth_info"] = AuthInfo.from_verified_signer( signer, max_verified_age_s=300.0, )The verifier MUST surface
signer.agent_urlfor the commercial-identity registry to dispatch — without it, the framework has no key to look up. The verifier is configured with theagent_urlclaim shape per the AdCP v3 profile; if it'sNonehere the verifier wasn't told to extract it and this helper raises :class:ValueErrorwith a pointer to the misconfiguration. Buyers don't see this — server boot time error.:param signer: The :class:
VerifiedSignerreturned from :func:verify_request_signature(). :param scopes: Granted scopes / capabilities. The verifier doesn't extract these — adopter middleware fills in scopes derived from token introspection or per-key policy. :param operator: Operator label for AdCP v3 multi-operator deployments (AAO community proxy). Most adopters leaveNone. :param extra: Adopter passthrough. :param max_verified_age_s: Maximum age (seconds) ofsigner.verified_atpermitted at construction time. When set andnow() - signer.verified_at > max_verified_age_s, raises :class:ValueError— adopter caching a staleVerifiedSignerand replaying it later fails fast rather than passing a stale-but- cryptographically-valid credential into the registry dispatch. DefaultNonekeeps the v6.0-alpha behavior; production sellers SHOULD set a short window (e.g. 300s) matching the RFC 9421 nonce TTL. :param now: Overridetime.time()formax_verified_age_scomparisons — only useful in tests. DefaultNoneuses wall-clock. :raises ValueError: whensigner.agent_urlisNone, or whenmax_verified_age_sis set andsigner.verified_atis older than the window.
Instance variables
var agent_url : str | Nonevar credential : Credential | Nonevar extra : Mapping[str, Any]var key_id : str | Nonevar kind : strvar operator : str | Nonevar principal : str | Nonevar scopes : list[str]
- 4.4.0 (this release) — flat-field synthesis still works but
warns. Adopter code stays runnable; the warning points at every
callsite that constructs
class RequestContext (request_id: str | None = None,
caller_identity: str | None = None,
tenant_id: str | None = None,
metadata: dict[str, Any] = <factory>,
resolved_adcp_version: str | None = None,
account: Account[TMeta] = <factory>,
auth_info: AuthInfo | None = None,
auth_principal: str | None = None,
buyer_agent: BuyerAgent | None = None,
transport: "Literal['mcp', 'a2a'] | None" = None,
now: datetime = <factory>,
state: StateReader = <factory>,
resolve: ResourceResolver = <factory>,
recipes: Mapping[str, Recipe] = <factory>)-
Expand source code
@dataclass class RequestContext(ToolContext, Generic[TMeta]): """Per-request context passed to every Protocol method. Subclasses :class:`adcp.server.ToolContext` so the existing framework primitives (idempotency middleware, observability, A2A executor) consume it as a ``ToolContext`` while adopter Protocol methods read the typed :attr:`account` directly. **Framework-only construction.** Adopter code receives a ``RequestContext`` from the framework on every dispatch via the hydration helper in ``adcp.decisioning.dispatch``. Direct construction is supported for tests only — production code that builds a ``RequestContext`` from outside the dispatch seam is a bug. Adopters who need to modify the context (custom middleware, test doubles for ``state`` / ``resolve``) should use :func:`dataclasses.replace`, not raw construction. Mirrors the TS-side ``to-context.ts:buildRequestContext`` contract. :param account: The resolved account, with typed ``metadata: TMeta``. The framework's idempotency middleware reads ``ctx.caller_identity`` for cache scoping; the dispatch adapter sets ``caller_identity = account.id`` so caching scopes per resolved account, not per raw auth principal. :param auth_info: Optional verified principal info. On bearer-token flows the dispatch helper synthesizes ``AuthInfo(kind="bearer", principal=...)`` from the :data:`adcp.server.auth.current_principal` ContextVar so adopters can branch on ``ctx.auth_info.kind == "bearer"`` (the typed flow discriminator) without reaching into framework-private state. ``None`` when the request is unauthenticated (dev / ``'derived'`` fixtures). :param now: Monotonic timestamp for the request — adopters use this rather than ``datetime.now()`` directly so tests can inject deterministic clocks. Adopters call :meth:`handoff_to_task` to promote a method to the HITL background-task path. The framework dispatcher detects the returned :class:`TaskHandoff` via type-identity and projects it to the wire ``Submitted`` envelope. **Identifier disambiguation — when to use which:** The context carries four identifier-shaped fields. Each has a distinct role; mixing them up is the most common adopter bug. ``account.id`` — "whose data is this?" The resolved tenant / account that owns the call. Read it to route the request to the right adapter instance, scope your DB queries, and stamp audit logs. ``auth_principal`` — "who's calling?" The verified caller's identity label and the typed read for adopter authorization checks. Populated from two sources depending on the auth flow: * **Signed-request flows** — sourced from :class:`AuthInfo.principal` (``agent_url`` for AdCP v3 signed-request agents per spec). * **Bearer-token flows** — sourced from the :data:`adcp.server.auth.current_principal` ContextVar that :class:`BearerTokenAuthMiddleware` populates (``Principal.caller_identity`` from the validator). The dispatch helper also synthesizes ``AuthInfo(kind="bearer", principal=...)`` so adopters can discriminate the flow via ``ctx.auth_info.kind == "bearer"``. Read it for per-principal ACLs *within* an account ("can principal X mutate this buy?"). ``None`` for unauthenticated dev fixtures. ``caller_identity`` — "what's the cache scope key?" Starts as the bare principal at the transport layer (:class:`ToolContext.caller_identity`), then the framework dispatch helper mutates it into the composite scope key (``<store_module>.<store_qualname>:<account_id>``) before the handler sees the :class:`RequestContext`. The idempotency middleware reads the composite form to scope the replay cache. **Do not read this for identity decisions** — by the time a handler observes the field it's a cache key, not a principal label. Use ``auth_principal`` for "who's calling?" and treat ``caller_identity`` as opaque (log / forward only; don't parse, compare, or rewrite). The composite format is framework-internal and any adopter assumption about its shape will break when the scope-key composition changes. ``tenant_id`` — "which transport tenant?" Inherited from :class:`ToolContext`; set by the transport layer before dispatch (typically from the host header or URL path on multi-tenant deployments). Usually equals ``account.id`` for ``'explicit'``-resolution adopters; can diverge for ``'derived'`` / ``'implicit'`` modes. Common patterns: * Routing to the right adapter? → ``ctx.account.metadata.adapter`` (typed via the ``TMeta`` generic). * Authorization check? → ``ctx.auth_principal`` (who's calling) against ``ctx.account.id`` (whose data they're touching). * Idempotency scope? → don't touch; the framework owns this. * Logging request provenance? → log all four; they're cheap. :param transport: The wire protocol that dispatched this call — ``"mcp"`` or ``"a2a"``. ``None`` when ``RequestContext`` is constructed in tests without a transport-aware ``ToolContext``, or when a custom ``context_factory`` omits ``metadata["transport"]``. Production dispatch always populates this field. Note: even when the server is started with ``transport="both"``, individual requests always resolve to exactly one of ``"mcp"`` or ``"a2a"`` — this field never carries ``"both"``. For code running outside a handler call stack, read :data:`adcp.server.current_transport` instead. :param state: Sync reads of framework-owned in-flight workflow state. Default is :class:`adcp.decisioning.state._NotYetWiredStateReader` — returns empty values + emits one-time UserWarning per method on first call. v6.1 wires the backing store. :param resolve: Async framework-mediated fetches with cache + validation. Default is :class:`adcp.decisioning.resolve._NotYetWiredResolver` — raises ``NotImplementedError`` on every call. v6.1 wires the backing fetchers. :param auth_principal: Typed convenience field carrying the verified principal label. Sourced from :class:`AuthInfo.principal` on signed-request flows and from the :data:`adcp.server.auth.current_principal` ContextVar on bearer-token flows (the framework's :class:`BearerTokenAuthMiddleware` populates the ContextVar; the dispatch helper reads it when ``auth_info`` is absent). The right read for "who's calling?" — distinct from ``caller_identity``, which the framework mutates into a composite cache scope key for idempotency. """ # Default factories so ``RequestContext()`` works in tests; in # production the dispatch adapter populates every field. account: Account[TMeta] = field(default_factory=lambda: Account(id="<unset>")) auth_info: AuthInfo | None = None auth_principal: str | None = None buyer_agent: BuyerAgent | None = None transport: Literal["mcp", "a2a"] | None = None now: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) state: StateReader = field(default_factory=_make_default_state_reader) resolve: ResourceResolver = field(default_factory=_make_default_resolver) # ``recipes`` — populated by the framework on dispatch paths that # hydrate a proposal (post-finalize ``create_media_buy`` / # ``update_media_buy`` / ``get_media_buy_delivery``). Empty mapping # by default so legacy / non-proposal flows see the v1 shape. See # ``docs/proposals/proposal-manager-v15-design.md`` § D3. recipes: Mapping[str, Recipe] = field(default_factory=dict) def handoff_to_task( self, fn: Callable[[Any], Awaitable[T] | T], ) -> TaskHandoff[T]: """Promote this call to a background task. The buyer sees ``{status: 'submitted', task_id}`` on the immediate response; the framework runs ``fn`` after returning, persists ``fn``'s terminal artifact to the task registry, and emits a push-notification webhook on terminal state. ``fn`` receives a ``TaskHandoffContext`` (defined in :mod:`adcp.decisioning.dispatch`) carrying: * ``id`` — framework-issued task UUID * ``update(progress)`` — write progress payload, transition ``'submitted'`` → ``'working'`` * ``heartbeat()`` — liveness signal (v6.1 stub) Adopter code passes either a coroutine function (``async def review_async(task_ctx): ...``) or a sync callable; the dispatcher detects which and runs it appropriately. For external workflows that complete on their own schedule (human queue review, batch jobs, Airflow DAGs, ML pipelines) — use :meth:`handoff_to_workflow` instead. The split is purely about where the work runs (in-process / framework-managed vs. adopter-owned external system). """ return TaskHandoff(fn) def handoff_to_workflow( self, fn: Callable[[Any], Awaitable[None] | None], ) -> WorkflowHandoff: """Promote this call to an externally-completed task. For workflows that run OUTSIDE the framework's process — human queue review (trafficker UI), nightly batch jobs, Airflow DAGs, ML pipelines, scheduled cron. The framework allocates a ``task_id``, calls ``fn`` ONCE synchronously (or awaits it if a coroutine) to register the work into the adopter's external system, persists ``submitted`` state, and returns the wire envelope. NO background coroutine runs in the framework. ``fn`` receives a :class:`TaskHandoffContext` carrying ``id`` (framework-allocated task_id) and ``_registry`` (adopter can stash a reference for later completion). The adopter's external workflow later calls ``registry.complete(task_id, result)`` or ``registry.fail(task_id, error)`` directly when the work finishes — minutes, hours, or days later. Buyer experience is identical to :meth:`handoff_to_task` — same ``{task_id, status: 'submitted'}`` wire envelope, same ``tasks/get`` polling, same push-notification webhook on terminal state. **Rollback.** If ``fn`` raises during enqueue, the framework discards the just-allocated task_id from the registry and propagates the exception (wrapped to ``AdcpError`` per the dispatch contract). Adopter enqueue fns that need transactional persistence wrap their own DB write in their own transaction; the framework's rollback is registry-side only. Example:: class TraffickerSeller(DecisioningPlatform): def __init__(self, review_queue, task_registry): self.review_queue = review_queue # Stash for later completion when human acts self.task_registry = task_registry def create_media_buy(self, req, ctx): if self._needs_human_approval(req): return ctx.handoff_to_workflow( lambda task_ctx: self._enqueue(task_ctx, req) ) return CreateMediaBuySuccess(media_buy_id="mb_1", ...) def _enqueue(self, task_ctx, req): self.review_queue.add( task_id=task_ctx.id, request_snapshot=req.model_dump(), ) # Elsewhere — Flask handler for the trafficker UI: async def on_decision(self, task_id, decision): if decision.approved: await self.task_registry.complete( task_id, CreateMediaBuySuccess(...).model_dump(), ) else: await self.task_registry.fail( task_id, AdcpError(...).to_wire(), ) See :class:`adcp.decisioning.WorkflowHandoff` for the full semantics. """ return WorkflowHandoff(fn)Per-request context passed to every Protocol method.
Subclasses :class:
ToolContextso the existing framework primitives (idempotency middleware, observability, A2A executor) consume it as aToolContextwhile adopter Protocol methods read the typed :attr:accountdirectly.Framework-only construction. Adopter code receives a
RequestContextfrom the framework on every dispatch via the hydration helper inadcp.decisioning.dispatch. Direct construction is supported for tests only — production code that builds aRequestContextfrom outside the dispatch seam is a bug. Adopters who need to modify the context (custom middleware, test doubles forstate/resolve) should use :func:dataclasses.replace, not raw construction. Mirrors the TS-sideto-context.ts:buildRequestContextcontract.:param account: The resolved account, with typed
metadata: TMeta. The framework's idempotency middleware readsctx.caller_identityfor cache scoping; the dispatch adapter setscaller_identity = account.idso caching scopes per resolved account, not per raw auth principal. :param auth_info: Optional verified principal info. On bearer-token flows the dispatch helper synthesizesAuthInfo(kind="bearer", principal=...)from the :data:adcp.server.auth.current_principalContextVar so adopters can branch onctx.auth_info.kind == "bearer"(the typed flow discriminator) without reaching into framework-private state.Nonewhen the request is unauthenticated (dev /'derived'fixtures). :param now: Monotonic timestamp for the request — adopters use this rather thandatetime.now()directly so tests can inject deterministic clocks.Adopters call :meth:
handoff_to_taskto promote a method to the HITL background-task path. The framework dispatcher detects the returned :class:TaskHandoffvia type-identity and projects it to the wireSubmittedenvelope.Identifier disambiguation — when to use which:
The context carries four identifier-shaped fields. Each has a distinct role; mixing them up is the most common adopter bug.
account.id— "whose data is this?" The resolved tenant / account that owns the call. Read it to route the request to the right adapter instance, scope your DB queries, and stamp audit logs.auth_principal— "who's calling?" The verified caller's identity label and the typed read for adopter authorization checks. Populated from two sources depending on the auth flow:* **Signed-request flows** — sourced from :class:<code><a title="adcp.decisioning.context.AuthInfo.principal" href="#adcp.decisioning.context.AuthInfo.principal">AuthInfo.principal</a></code> (<code>agent\_url</code> for AdCP v3 signed-request agents per spec). * **Bearer-token flows** — sourced from the :data:<code>adcp.server.auth.current\_principal</code> ContextVar that :class:<code>BearerTokenAuthMiddleware</code> populates (<code>Principal.caller\_identity</code> from the validator). The dispatch helper also synthesizes ``AuthInfo(kind="bearer", principal=...)`` so adopters can discriminate the flow via ``ctx.auth_info.kind == "bearer"``. Read it for per-principal ACLs *within* an account ("can principal X mutate this buy?"). <code>None</code> for unauthenticated dev fixtures.caller_identity— "what's the cache scope key?" Starts as the bare principal at the transport layer (:class:ToolContext.caller_identity), then the framework dispatch helper mutates it into the composite scope key (<store_module>.<store_qualname>:<account_id>) before the handler sees the :class:RequestContext. The idempotency middleware reads the composite form to scope the replay cache.**Do not read this for identity decisions** — by the time a handler observes the field it's a cache key, not a principal label. Use <code>auth\_principal</code> for "who's calling?" and treat <code>caller\_identity</code> as opaque (log / forward only; don't parse, compare, or rewrite). The composite format is framework-internal and any adopter assumption about its shape will break when the scope-key composition changes.tenant_id— "which transport tenant?" Inherited from :class:ToolContext; set by the transport layer before dispatch (typically from the host header or URL path on multi-tenant deployments). Usually equalsaccount.idfor'explicit'-resolution adopters; can diverge for'derived'/'implicit'modes.Common patterns:
- Routing to the right adapter? →
ctx.account.metadata.adapter(typed via theTMetageneric). - Authorization check? →
ctx.auth_principal(who's calling) againstctx.account.id(whose data they're touching). - Idempotency scope? → don't touch; the framework owns this.
- Logging request provenance? → log all four; they're cheap.
:param transport: The wire protocol that dispatched this call —
"mcp"or"a2a".NonewhenRequestContextis constructed in tests without a transport-awareToolContext, or when a customcontext_factoryomitsmetadata["transport"]. Production dispatch always populates this field. Note: even when the server is started withtransport="both", individual requests always resolve to exactly one of"mcp"or"a2a"— this field never carries"both". For code running outside a handler call stack, read :data:adcp.server.current_transportinstead. :param state: Sync reads of framework-owned in-flight workflow state. Default is :class:adcp.decisioning.state._NotYetWiredStateReader— returns empty values + emits one-time UserWarning per method on first call. v6.1 wires the backing store. :param resolve: Async framework-mediated fetches with cache + validation. Default is :class:adcp.decisioning.resolve._NotYetWiredResolver— raisesNotImplementedErroron every call. v6.1 wires the backing fetchers. :param auth_principal: Typed convenience field carrying the verified principal label. Sourced from :class:AuthInfo.principalon signed-request flows and from the :data:adcp.server.auth.current_principalContextVar on bearer-token flows (the framework's :class:BearerTokenAuthMiddlewarepopulates the ContextVar; the dispatch helper reads it whenauth_infois absent). The right read for "who's calling?" — distinct fromcaller_identity, which the framework mutates into a composite cache scope key for idempotency.Ancestors
- ToolContext
- typing.Generic
Instance variables
var account : Account[TMeta]var auth_info : AuthInfo | Nonevar auth_principal : str | Nonevar buyer_agent : BuyerAgent | Nonevar now : datetimevar recipes : Mapping[str, Recipe]var resolve : ResourceResolvervar state : StateReadervar transport : Literal['mcp', 'a2a'] | None
Methods
def handoff_to_task(self, fn: Callable[[Any], Awaitable[T] | T]) ‑> TaskHandoff[~T]-
Expand source code
def handoff_to_task( self, fn: Callable[[Any], Awaitable[T] | T], ) -> TaskHandoff[T]: """Promote this call to a background task. The buyer sees ``{status: 'submitted', task_id}`` on the immediate response; the framework runs ``fn`` after returning, persists ``fn``'s terminal artifact to the task registry, and emits a push-notification webhook on terminal state. ``fn`` receives a ``TaskHandoffContext`` (defined in :mod:`adcp.decisioning.dispatch`) carrying: * ``id`` — framework-issued task UUID * ``update(progress)`` — write progress payload, transition ``'submitted'`` → ``'working'`` * ``heartbeat()`` — liveness signal (v6.1 stub) Adopter code passes either a coroutine function (``async def review_async(task_ctx): ...``) or a sync callable; the dispatcher detects which and runs it appropriately. For external workflows that complete on their own schedule (human queue review, batch jobs, Airflow DAGs, ML pipelines) — use :meth:`handoff_to_workflow` instead. The split is purely about where the work runs (in-process / framework-managed vs. adopter-owned external system). """ return TaskHandoff(fn)Promote this call to a background task.
The buyer sees
{status: 'submitted', task_id}on the immediate response; the framework runsfnafter returning, persistsfn's terminal artifact to the task registry, and emits a push-notification webhook on terminal state.fnreceives aTaskHandoffContext(defined in :mod:adcp.decisioning.dispatch) carrying:id— framework-issued task UUIDupdate(progress)— write progress payload, transition'submitted'→'working'heartbeat()— liveness signal (v6.1 stub)
Adopter code passes either a coroutine function (
async def review_async(task_ctx): ...) or a sync callable; the dispatcher detects which and runs it appropriately.For external workflows that complete on their own schedule (human queue review, batch jobs, Airflow DAGs, ML pipelines) — use :meth:
handoff_to_workflowinstead. The split is purely about where the work runs (in-process / framework-managed vs. adopter-owned external system). def handoff_to_workflow(self, fn: Callable[[Any], Awaitable[None] | None]) ‑> WorkflowHandoff-
Expand source code
def handoff_to_workflow( self, fn: Callable[[Any], Awaitable[None] | None], ) -> WorkflowHandoff: """Promote this call to an externally-completed task. For workflows that run OUTSIDE the framework's process — human queue review (trafficker UI), nightly batch jobs, Airflow DAGs, ML pipelines, scheduled cron. The framework allocates a ``task_id``, calls ``fn`` ONCE synchronously (or awaits it if a coroutine) to register the work into the adopter's external system, persists ``submitted`` state, and returns the wire envelope. NO background coroutine runs in the framework. ``fn`` receives a :class:`TaskHandoffContext` carrying ``id`` (framework-allocated task_id) and ``_registry`` (adopter can stash a reference for later completion). The adopter's external workflow later calls ``registry.complete(task_id, result)`` or ``registry.fail(task_id, error)`` directly when the work finishes — minutes, hours, or days later. Buyer experience is identical to :meth:`handoff_to_task` — same ``{task_id, status: 'submitted'}`` wire envelope, same ``tasks/get`` polling, same push-notification webhook on terminal state. **Rollback.** If ``fn`` raises during enqueue, the framework discards the just-allocated task_id from the registry and propagates the exception (wrapped to ``AdcpError`` per the dispatch contract). Adopter enqueue fns that need transactional persistence wrap their own DB write in their own transaction; the framework's rollback is registry-side only. Example:: class TraffickerSeller(DecisioningPlatform): def __init__(self, review_queue, task_registry): self.review_queue = review_queue # Stash for later completion when human acts self.task_registry = task_registry def create_media_buy(self, req, ctx): if self._needs_human_approval(req): return ctx.handoff_to_workflow( lambda task_ctx: self._enqueue(task_ctx, req) ) return CreateMediaBuySuccess(media_buy_id="mb_1", ...) def _enqueue(self, task_ctx, req): self.review_queue.add( task_id=task_ctx.id, request_snapshot=req.model_dump(), ) # Elsewhere — Flask handler for the trafficker UI: async def on_decision(self, task_id, decision): if decision.approved: await self.task_registry.complete( task_id, CreateMediaBuySuccess(...).model_dump(), ) else: await self.task_registry.fail( task_id, AdcpError(...).to_wire(), ) See :class:`adcp.decisioning.WorkflowHandoff` for the full semantics. """ return WorkflowHandoff(fn)Promote this call to an externally-completed task.
For workflows that run OUTSIDE the framework's process — human queue review (trafficker UI), nightly batch jobs, Airflow DAGs, ML pipelines, scheduled cron. The framework allocates a
task_id, callsfnONCE synchronously (or awaits it if a coroutine) to register the work into the adopter's external system, persistssubmittedstate, and returns the wire envelope. NO background coroutine runs in the framework.fnreceives a :class:TaskHandoffContextcarryingid(framework-allocated task_id) and_registry(adopter can stash a reference for later completion). The adopter's external workflow later callsregistry.complete(task_id, result)orregistry.fail(task_id, error)directly when the work finishes — minutes, hours, or days later.Buyer experience is identical to :meth:
handoff_to_task— same{task_id, status: 'submitted'}wire envelope, sametasks/getpolling, same push-notification webhook on terminal state.Rollback. If
fnraises during enqueue, the framework discards the just-allocated task_id from the registry and propagates the exception (wrapped toAdcpErrorper the dispatch contract). Adopter enqueue fns that need transactional persistence wrap their own DB write in their own transaction; the framework's rollback is registry-side only.Example::
class TraffickerSeller(DecisioningPlatform): def __init__(self, review_queue, task_registry): self.review_queue = review_queue # Stash for later completion when human acts self.task_registry = task_registry def create_media_buy(self, req, ctx): if self._needs_human_approval(req): return ctx.handoff_to_workflow( lambda task_ctx: self._enqueue(task_ctx, req) ) return CreateMediaBuySuccess(media_buy_id="mb_1", ...) def _enqueue(self, task_ctx, req): self.review_queue.add( task_id=task_ctx.id, request_snapshot=req.model_dump(), ) # Elsewhere — Flask handler for the trafficker UI: async def on_decision(self, task_id, decision): if decision.approved: await self.task_registry.complete( task_id, CreateMediaBuySuccess(...).model_dump(), ) else: await self.task_registry.fail( task_id, AdcpError(...).to_wire(), )See :class:
WorkflowHandofffor the full semantics.
- Routing to the right adapter? →