diff --git a/src/adcp/decisioning/accounts.py b/src/adcp/decisioning/accounts.py index 8d81ca232..d61b52a30 100644 --- a/src/adcp/decisioning/accounts.py +++ b/src/adcp/decisioning/accounts.py @@ -1,20 +1,47 @@ """Account resolution: ``AccountStore`` Protocol + three reference impls. -Adopters pick a resolution mode at registration time: - -* :class:`SingletonAccounts` — single-process / single-platform - deployments (Innovid training-agent, single-publisher proof-of-concept). - Synthesizes ``account.id`` per verified principal so idempotency - scopes correctly across distinct callers. -* :class:`ExplicitAccounts` — multi-tenant where the URL or request - body identifies the account (``/tenants/``, ``account.account_id`` - in body). Resolves by the wire reference. -* :class:`FromAuthAccounts` — multi-tenant or single-tenant where the - verified auth principal identifies the account (signed-request bound, - OAuth bearer bound). Resolves by ``ctx.auth_info.principal``. +Adopters pick a resolution mode at registration time. The mode literal +mirrors the JS-side ``AccountStore.resolution`` field +(``src/lib/server/decisioning/account.ts``) for cross-language parity: + +* :class:`SingletonAccounts` (``resolution='derived'``) — single-process + / single-platform deployments (Innovid training-agent, single-publisher + proof-of-concept). Synthesizes ``account.id`` per verified principal + so idempotency scopes correctly across distinct callers. +* :class:`ExplicitAccounts` (``resolution='explicit'``) — multi-tenant + where the URL or request body identifies the account + (``/tenants/``, ``account.account_id`` in body). Resolves by the + wire reference. +* :class:`FromAuthAccounts` (``resolution='implicit'``) — multi-tenant + or single-tenant where the verified auth principal identifies the + account (signed-request bound, OAuth bearer bound). Resolves by + ``ctx.auth_info.principal``. Adopters with shapes that don't fit these three implement the :class:`AccountStore` Protocol directly. + +Spec-agent vs auth-layer principal: + AdCP v3 introduced agent-to-agent flows where the calling principal + IS an agent identified by a stable ``agent_url`` (the agent's + well-known URL acting as a global identifier) and a key id (``kid``) + on the request signature. The framework's ``AuthInfo.principal`` + is the verified-principal opaque string the auth layer surfaces — + the documented convention is ``agent_url`` for signed-request + agents, OAuth subject claim for bearer tokens, mTLS subject for + client-cert flows. Adopters wiring ``FromAuthAccounts`` decide + what string their auth middleware projects onto + ``ctx.auth_info.principal``; the framework treats it opaquely. + + The SDK's signing primitives in :mod:`adcp.signing` verify the + request signature against a JWKS provider; it's the adopter's + middleware (today) or the framework's built-in + ``SignedRequestAuth`` adapter wrapper (Tier 2, lands in 4.5.0) + that takes the verified result and writes ``AuthInfo.principal = + agent_url`` onto the dispatch context. Adopters wiring this + manually before 4.5.0 ships should follow that convention to keep + ``adcp.adagents`` (the spec validator that ties an ``agent_url`` + to a seller's published ``adagents.json`` whitelist) reading the + right key. """ from __future__ import annotations @@ -37,18 +64,22 @@ class AccountStore(Protocol, Generic[TMeta]): The framework calls :meth:`resolve` for every tool dispatch (before the handler method runs). Adopters in ``'explicit'`` mode - use ``ref.account_id`` from the wire; ``'from_auth'`` mode reads + use ``ref.account_id`` from the wire; ``'implicit'`` mode reads ``ctx.auth_info`` to look up the principal-bound account; - ``'singleton'`` mode synthesizes a per-principal account from the + ``'derived'`` mode synthesizes a per-principal account from the one platform. The :attr:`resolution` literal is a structural attribute the framework reads at server boot — used by :func:`validate_platform` to fail fast on misconfigured deployments (e.g. - ``'singleton'`` registered into a multi-tenant ``TenantRegistry``). + ``'derived'`` registered into a multi-tenant ``TenantRegistry``). + Mirrors the JS-side literal for cross-language parity: + ``'explicit'`` (wire ref drives lookup), ``'implicit'`` (verified + auth principal drives lookup), ``'derived'`` (single-platform with + per-principal id synthesis). """ - resolution: Literal["explicit", "from_auth", "singleton"] + resolution: Literal["explicit", "implicit", "derived"] def resolve( self, @@ -60,10 +91,10 @@ def resolve( :param ref: The wire reference object (typically ``request.account`` carrying ``account_id`` / ``account_ref``). ``None`` for tools that don't carry an - explicit account ref — adopters in ``'singleton'`` / - ``'from_auth'`` modes ignore it. + explicit account ref — adopters in ``'derived'`` / + ``'implicit'`` modes ignore it. :param auth_info: Verified principal info. ``None`` for - unauthenticated requests (dev / ``'singleton'`` fixtures). + unauthenticated requests (dev / ``'derived'`` fixtures). :raises adcp.decisioning.AdcpError: ``code='ACCOUNT_NOT_FOUND'`` when the resolution can't produce a valid account. @@ -111,7 +142,7 @@ class TrainingAgentSeller(DecisioningPlatform): right TypedDict / dataclass instance. """ - resolution: Literal["singleton"] = "singleton" + resolution: Literal["derived"] = "derived" def __init__( self, @@ -236,7 +267,7 @@ class MeasurementVendor(DecisioningPlatform): :class:`Account` instance. Sync or async. """ - resolution: Literal["from_auth"] = "from_auth" + resolution: Literal["implicit"] = "implicit" def __init__( self, diff --git a/src/adcp/decisioning/context.py b/src/adcp/decisioning/context.py index ada44dd8b..5b5d91ee6 100644 --- a/src/adcp/decisioning/context.py +++ b/src/adcp/decisioning/context.py @@ -92,7 +92,7 @@ class RequestContext(ToolContext, Generic[TMeta]): sets ``caller_identity = account.id`` so caching scopes per resolved account, not per raw auth principal. :param auth_info: Optional verified principal info. ``None`` when - the request is unauthenticated (dev / 'singleton' fixtures). + 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. @@ -102,6 +102,51 @@ class RequestContext(ToolContext, Generic[TMeta]): 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. The string varies by + auth shape: ``agent_url`` for AdCP v3 signed-request agents + (the documented convention; the SDK's signed-request adapter + wrappers ship in 4.5.0), OAuth subject claim for bearer + flows, mTLS subject for client-cert flows. Read it for + per-principal ACLs *within* an account ("can principal X + mutate this buy?"). + + ``caller_identity`` — "what's the cache scope key?" + Composite framework-set key + (``.:``) used by + the idempotency middleware to scope the replay cache. + Treat as opaque. Adopter code may log or forward it + (rate-limiting, audit) but should not parse, compare, or + rewrite it — the 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 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 diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 49ddc08cf..1554ab956 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -162,8 +162,9 @@ async def _resolve_account( Pulls auth info from ``ctx.metadata['auth_info']`` when the operator's ``context_factory`` populates it; otherwise None. Adopter ``AccountStore`` impls handle missing-auth cases per - their own resolution mode (singleton tolerates None; - from_auth raises ``AUTH_INVALID``; explicit resolves by ref). + their own resolution mode (``'derived'`` tolerates None; + ``'implicit'`` raises ``AUTH_INVALID``; ``'explicit'`` resolves + by ref). ``AccountStore.resolve`` takes a dict — convert the typed Pydantic ``AccountReference`` via ``model_dump()`` so adopter store impls see a normalized shape. @@ -196,7 +197,7 @@ def _extract_auth_info(ctx: ToolContext) -> AuthInfo | None: principal/scope info. Adopter conventions vary; this helper checks for an ``adcp.auth_info`` key — Stage 3 ``serve()`` wiring sets this from the canonical principal. Returns None when no auth key - is present (dev / singleton fixtures). + is present (dev / ``'derived'`` fixtures). """ raw = ctx.metadata.get("adcp.auth_info") if ctx.metadata else None if isinstance(raw, AuthInfo): diff --git a/src/adcp/decisioning/types.py b/src/adcp/decisioning/types.py index 354e49b69..37fe9ea93 100644 --- a/src/adcp/decisioning/types.py +++ b/src/adcp/decisioning/types.py @@ -167,8 +167,47 @@ def create_media_buy(self, req, ctx): if self._is_pre_approved(req, ctx.account): # Sync fast path — return Success directly return CreateMediaBuySuccess(media_buy_id="mb_1", ...) - # HITL slow path — hand off to background trafficker review + # Framework-async slow path — hand off to background work return ctx.handoff_to_task(self._review_async) + + **What TaskHandoff is for** — short, framework-mediated async work + where the adopter awaits an external system (DSP API call, + classifier inference, third-party brand-safety scan, generative + creative render) inside a coroutine. The handoff fn runs in the + same process, the framework awaits it, persists the terminal + artifact, and emits a webhook on completion. Typical wall-clock: + seconds to minutes. + + **What TaskHandoff is NOT for** — human-driven HITL workflows where + a real person eventually clicks "approve" in a queue. The handoff + fn would either block the framework's background runner indefinitely + (until the human acts), or poll an external queue (which doesn't + fit the "fn returns terminal artifact" contract). For human-approval + workflows, the recommended pattern is: + + 1. Adopter persists the in-flight buy in their own DB and returns + ``input-required`` (NOT a TaskHandoff) on the synchronous arm, + carrying a stable ``task_id`` they allocated. The + ``input-required`` status is in the spec task-status enum + (``schemas/cache/enums/task-status.json``); the per-tool + ``*_async_response_input_required`` envelopes are in + :mod:`adcp.types`. + 2. Trafficker UI flips the row to approved/rejected on its own + schedule. + 3. Adopter's webhook emitter fires the terminal webhook to the + buyer when the human acts. Use the SDK's webhook primitives in + :mod:`adcp.webhooks` (payload builders) + + :mod:`adcp.webhook_sender` (HMAC-SHA256 signing + IP-pinned + transport delivery) — same wire shape the framework uses on the + TaskHandoff path. + + The adopter owns the whole lifecycle in the human-driven case; the + framework's TaskHandoff projector exists only for the "fn returns + terminal artifact within a reasonable wall-clock" shape. v6.1 may + add a richer ``ctx.handoff_to_human()`` primitive for the queued- + approval pattern; a worked end-to-end example lands with the + multi-tenant ``hello_publisher.py`` example in 4.5.0. For v6.0, + keep human approvals out of the TaskHandoff path. """ __slots__ = ("_fn",) @@ -236,7 +275,7 @@ class Account(Generic[TMeta]): typechecks inside method bodies. The framework's idempotency middleware scopes its cache by - ``account.id``. Adopters in 'singleton' resolution mode MUST + ``account.id``. Adopters in ``'derived'`` resolution mode MUST synthesize per-principal IDs (e.g. ``f"training-agent:{principal}"``) or buyer-to-buyer cache leakage is possible — see :class:`adcp.decisioning.SingletonAccounts`. diff --git a/tests/test_decisioning_types.py b/tests/test_decisioning_types.py index 4419b2bef..234279f53 100644 --- a/tests/test_decisioning_types.py +++ b/tests/test_decisioning_types.py @@ -285,10 +285,14 @@ def test_account_store_protocol_runtime_checkable() -> None: def test_account_store_resolution_literal() -> None: """``resolution`` is a structural literal the framework reads at - server boot for ``validate_platform`` checks.""" - assert SingletonAccounts(account_id="x").resolution == "singleton" + server boot for ``validate_platform`` checks. Mirrors the JS-side + literal (``src/lib/server/decisioning/account.ts``) for + cross-language parity: ``'explicit'`` (wire ref drives lookup), + ``'implicit'`` (verified auth principal drives lookup), + ``'derived'`` (single-platform with per-principal id synthesis).""" + assert SingletonAccounts(account_id="x").resolution == "derived" assert ExplicitAccounts(loader=lambda _x: Account(id="y")).resolution == "explicit" - assert FromAuthAccounts(loader=lambda _x: Account(id="z")).resolution == "from_auth" + assert FromAuthAccounts(loader=lambda _x: Account(id="z")).resolution == "implicit" # ---- DecisioningPlatform contract ---- @@ -305,4 +309,4 @@ class HelloSeller(DecisioningPlatform): s = HelloSeller() assert s.capabilities.specialisms == ["sales-non-guaranteed"] - assert s.accounts.resolution == "singleton" + assert s.accounts.resolution == "derived"