Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 52 additions & 21 deletions src/adcp/decisioning/accounts.py
Original file line number Diff line number Diff line change
@@ -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/<id>``, ``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/<id>``, ``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
Expand All @@ -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,
Expand All @@ -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.

Expand Down Expand Up @@ -111,7 +142,7 @@ class TrainingAgentSeller(DecisioningPlatform):
right TypedDict / dataclass instance.
"""

resolution: Literal["singleton"] = "singleton"
resolution: Literal["derived"] = "derived"

def __init__(
self,
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 46 additions & 1 deletion src/adcp/decisioning/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
(``<store_module>.<store_qualname>:<account_id>``) 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
Expand Down
7 changes: 4 additions & 3 deletions src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down
43 changes: 41 additions & 2 deletions src/adcp/decisioning/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)
Expand Down Expand Up @@ -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`.
Expand Down
12 changes: 8 additions & 4 deletions tests/test_decisioning_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----
Expand All @@ -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"
Loading