Module adcp.decisioning.accounts
Account resolution: AccountStore Protocol + three reference impls.
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). Synthesizesaccount.idper 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_idin 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 byctx.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.
Global variables
var TMeta-
Per-platform metadata generic.
Classes
class AccountStore (*args, **kwargs)-
Expand source code
@runtime_checkable class AccountStore(Protocol, Generic[TMeta]): """Resolves a wire reference + auth context to an :class:`Account`. 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; ``'implicit'`` mode reads ``ctx.auth_info`` to look up the principal-bound account; ``'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. ``'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). **Multi-tenant deployments — Account.id is the encoding seam.** Applies to seller-side adopters resolving incoming AdCP requests. DSP-side adopters wiring this SDK as a client construct :class:`~adcp.types.AccountReference` directly for outbound calls and don't go through this seam. Buyers send a per-tenant ``account_ref`` on the wire; sellers don't control what string a buyer picks, and the same ``account_ref`` ("acme", "default", sequential ids) may arrive from buyers calling different seller tenants. The transport sets ``tenant_id`` from the Host header (via :class:`~adcp.server.SubdomainTenantMiddleware`), and ``resolve()`` is the single layer that mints ``Account.id`` — the framework treats that id as opaque from here onward and threads it into every downstream store (:class:`~adcp.decisioning.ProposalStore`, :class:`~adcp.decisioning.TaskRegistry`, framework idempotency cache, future media-buy stores) as the canonical scope key. Multi-tenant adopters compose the tenant scope INTO ``Account.id`` here, so every downstream store sees a globally-unique identifier and never has to know about tenants:: class MyAccountStore: resolution = "explicit" async def resolve(self, ref, auth_info=None): tenant_id = self._tenant_from(auth_info) buyer_ref = (ref or {}).get("account_id", "default") return Account( id=f"{tenant_id}:{buyer_ref}", # globally unique metadata={"tenant_id": tenant_id}, ) Pushing tenant scope DOWN into a downstream store (parsing ``account_id`` back out inside ``ProposalStore.put_draft``, or threading a separate ``tenant_id`` argument through every Protocol method) is the wrong layer: it forces every store to re-derive what ``resolve()`` already knows, and adopter encoding-convention changes silently break every downstream Protocol call site. **Pick a stable tenant identifier.** The tenant value baked into ``Account.id`` lives forever in every downstream store's row keys and the framework's idempotency cache. Use a UUID or immutable slug, not a user-facing display name — a tenant rename (vanity URL change, white-label rebrand) that mutates the prefix would orphan every proposal, task, and cached response keyed under the old value. :func:`~adcp.decisioning.create_tenant_store` ships this pattern as a typed factory with a baked-in per-entry tenant-isolation gate — use it directly unless you have a reason to write your own. """ resolution: ClassVar[str] def resolve( self, ref: dict[str, Any] | None, auth_info: AuthInfo | None = None, ) -> Awaitable[Account[TMeta]] | Account[TMeta]: """Return the resolved :class:`Account` or raise on miss. :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 ``'derived'`` / ``'implicit'`` modes ignore it. :param auth_info: Verified principal info. ``None`` for unauthenticated requests (dev / ``'derived'`` fixtures). :raises adcp.decisioning.AdcpError: ``code='ACCOUNT_NOT_FOUND'`` when the resolution can't produce a valid account. Implementations may be sync or async; the dispatch adapter detects via :func:`inspect.iscoroutine` at call time. """ ... # ----- Optional v6 surfaces ----- # # The methods below are documented on the Protocol class for # discoverability but live on the SEPARATE :class:`AccountStoreUpsert`, # :class:`AccountStoreList`, and :class:`AccountStoreSyncGovernance` # Protocols below — keeping them off the runtime-checkable # :class:`AccountStore` Protocol so an adopter who only implements # ``resolve`` (the minimum viable AccountStore) still passes # ``isinstance(store, AccountStore)``. The framework's dispatch # shim probes via :func:`hasattr` for the optional methods at # call time and surfaces ``UNSUPPORTED_FEATURE`` when absent. # # See: # * :meth:`AccountStoreUpsertRequest.upsert_request` — ``sync_accounts`` # with request-level fields preserved # * :meth:`AccountStoreUpsert.upsert` — legacy ``sync_accounts`` # per-account refs only # * :meth:`AccountStoreList.list` — ``list_accounts`` # * :meth:`AccountStoreSyncGovernance.sync_governance`Resolves a wire reference + auth context to an :class:
Account.The framework calls :meth:
resolvefor every tool dispatch (before the handler method runs). Adopters in'explicit'mode useref.account_idfrom the wire;'implicit'mode readsctx.auth_infoto look up the principal-bound account;'derived'mode synthesizes a per-principal account from the one platform.The :attr:
resolutionliteral is a structural attribute the framework reads at server boot — used by :func:validate_platformto fail fast on misconfigured deployments (e.g.'derived'registered into a multi-tenantTenantRegistry). 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).Multi-tenant deployments — Account.id is the encoding seam.
Applies to seller-side adopters resolving incoming AdCP requests. DSP-side adopters wiring this SDK as a client construct :class:
~adcp.types.AccountReferencedirectly for outbound calls and don't go through this seam.Buyers send a per-tenant
account_refon the wire; sellers don't control what string a buyer picks, and the sameaccount_ref("acme", "default", sequential ids) may arrive from buyers calling different seller tenants. The transport setstenant_idfrom the Host header (via :class:~adcp.server.SubdomainTenantMiddleware), andresolve()is the single layer that mintsAccount.id— the framework treats that id as opaque from here onward and threads it into every downstream store (:class:~adcp.decisioning.ProposalStore, :class:~adcp.decisioning.TaskRegistry, framework idempotency cache, future media-buy stores) as the canonical scope key.Multi-tenant adopters compose the tenant scope INTO
Account.idhere, so every downstream store sees a globally-unique identifier and never has to know about tenants::class MyAccountStore: resolution = "explicit" async def resolve(self, ref, auth_info=None): tenant_id = self._tenant_from(auth_info) buyer_ref = (ref or {}).get("account_id", "default") return Account( id=f"{tenant_id}:{buyer_ref}", # globally unique metadata={"tenant_id": tenant_id}, )Pushing tenant scope DOWN into a downstream store (parsing
account_idback out insideProposalStore.put_draft, or threading a separatetenant_idargument through every Protocol method) is the wrong layer: it forces every store to re-derive whatresolve()already knows, and adopter encoding-convention changes silently break every downstream Protocol call site.Pick a stable tenant identifier. The tenant value baked into
Account.idlives forever in every downstream store's row keys and the framework's idempotency cache. Use a UUID or immutable slug, not a user-facing display name — a tenant rename (vanity URL change, white-label rebrand) that mutates the prefix would orphan every proposal, task, and cached response keyed under the old value.:func:
~adcp.decisioning.create_tenant_storeships this pattern as a typed factory with a baked-in per-entry tenant-isolation gate — use it directly unless you have a reason to write your own.Ancestors
- typing.Protocol
- typing.Generic
Class variables
var resolution : ClassVar[str]
Methods
def resolve(self, ref: dict[str, Any] | None, auth_info: AuthInfo | None = None) ‑> Awaitable[Account[~TMeta]] | Account[~TMeta]-
Expand source code
def resolve( self, ref: dict[str, Any] | None, auth_info: AuthInfo | None = None, ) -> Awaitable[Account[TMeta]] | Account[TMeta]: """Return the resolved :class:`Account` or raise on miss. :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 ``'derived'`` / ``'implicit'`` modes ignore it. :param auth_info: Verified principal info. ``None`` for unauthenticated requests (dev / ``'derived'`` fixtures). :raises adcp.decisioning.AdcpError: ``code='ACCOUNT_NOT_FOUND'`` when the resolution can't produce a valid account. Implementations may be sync or async; the dispatch adapter detects via :func:`inspect.iscoroutine` at call time. """ ...Return the resolved :class:
Accountor raise on miss.:param ref: The wire reference object (typically
request.accountcarryingaccount_id/account_ref).Nonefor tools that don't carry an explicit account ref — adopters in'derived'/'implicit'modes ignore it. :param auth_info: Verified principal info.Nonefor unauthenticated requests (dev /'derived'fixtures). :raises adcp.decisioning.AdcpError:code='ACCOUNT_NOT_FOUND'when the resolution can't produce a valid account.Implementations may be sync or async; the dispatch adapter detects via :func:
inspect.iscoroutineat call time.
class AccountStoreList (*args, **kwargs)-
Expand source code
@runtime_checkable class AccountStoreList(Protocol, Generic[TMeta]): """``list_accounts`` API surface. Optional adopter-side feature. Framework wraps the returned account list with the cursor envelope and projects each account through :func:`to_wire_account` (stripping framework-internal fields and applying the write-only strips for ``billing_entity.bank`` and ``governance_agents[].authentication``). **Security migration note.** Pre-this-release, adopters had no way to scope ``list_accounts`` per-principal — impls either returned all accounts (over-disclosure) or rejected the operation. Post-this-release, scoping becomes possible via ``ctx.agent``. **This is opt-in, not automatic.** Multi-tenant adopters MUST add principal scoping in their impl; without it, every authenticated caller sees every account. """ def list( self, filter: dict[str, Any] | None = None, ctx: ResolveContext | None = None, ) -> Awaitable[list[Account[TMeta]]] | list[Account[TMeta]]: """Return the accounts visible to the calling principal. :param filter: Wire-shape filter object — ``status`` / ``sandbox`` / pagination. Pass-through from the parsed wire request. :param ctx: Per-request context. ``ctx.auth_info`` and ``ctx.agent`` carry the caller's principal — adopters scope the listing per-principal (e.g., return only accounts visible to the calling buyer agent) without re-deriving identity from the request. """ ...list_accountsAPI surface. Optional adopter-side feature.Framework wraps the returned account list with the cursor envelope and projects each account through :func:
to_wire_account(stripping framework-internal fields and applying the write-only strips forbilling_entity.bankandgovernance_agents[].authentication).Security migration note. Pre-this-release, adopters had no way to scope
list_accountsper-principal — impls either returned all accounts (over-disclosure) or rejected the operation. Post-this-release, scoping becomes possible viactx.agent. This is opt-in, not automatic. Multi-tenant adopters MUST add principal scoping in their impl; without it, every authenticated caller sees every account.Ancestors
- typing.Protocol
- typing.Generic
Methods
def list(self,
filter: dict[str, Any] | None = None,
ctx: ResolveContext | None = None) ‑> collections.abc.Awaitable[list[Account[~TMeta]]] | list[Account[~TMeta]]-
Expand source code
def list( self, filter: dict[str, Any] | None = None, ctx: ResolveContext | None = None, ) -> Awaitable[list[Account[TMeta]]] | list[Account[TMeta]]: """Return the accounts visible to the calling principal. :param filter: Wire-shape filter object — ``status`` / ``sandbox`` / pagination. Pass-through from the parsed wire request. :param ctx: Per-request context. ``ctx.auth_info`` and ``ctx.agent`` carry the caller's principal — adopters scope the listing per-principal (e.g., return only accounts visible to the calling buyer agent) without re-deriving identity from the request. """ ...Return the accounts visible to the calling principal.
:param filter: Wire-shape filter object —
status/sandbox/ pagination. Pass-through from the parsed wire request. :param ctx: Per-request context.ctx.auth_infoandctx.agentcarry the caller's principal — adopters scope the listing per-principal (e.g., return only accounts visible to the calling buyer agent) without re-deriving identity from the request.
class AccountStoreSyncGovernance (*args, **kwargs)-
Expand source code
@runtime_checkable class AccountStoreSyncGovernance(Protocol): """``sync_governance`` API surface. Optional adopter-side feature. Buyers register governance agent endpoints per-account; the seller persists the binding and consults the agents during media buy lifecycle events via ``check_governance``. Adopters that don't model buyer-supplied governance agents (most direct sellers) leave this unimplemented and the framework returns ``UNSUPPORTED_FEATURE``. """ def sync_governance( self, entries: list[SyncGovernanceEntry], ctx: ResolveContext | None = None, ) -> Awaitable[list[SyncGovernanceResultRow]] | list[SyncGovernanceResultRow]: """Persist the per-entry governance-agent bindings. ``entries`` is the wire request's ``accounts[]`` — each entry pairs an :class:`AccountReference` with its ``governance_agents[]``. The framework has already deduped on ``idempotency_key`` and stripped wire metadata (``adcp_major_version``, ``context``, ``ext``) before invoking this method. **Replace semantics, per spec.** Each call REPLACES the previously synced governance agents for the referenced account. An entry whose ``governance_agents`` is empty clears the binding for that account. **Write-only credentials.** Each ``governance_agents[i].authentication.credentials`` is the bearer the seller presents to that governance agent on outbound ``check_governance`` calls. Persist them — silently dropping ships unauthenticated requests once cross-agent calls are wired. The framework strips ``authentication`` from each ``governance_agents[i]`` of every row before serialization (:func:`to_wire_sync_governance_row`), so credentials never reach the response wire OR the idempotency replay cache, even if an adopter returns a loosely-typed row that spreads the input. Do not rely on Python type hints alone — the strip is enforced at the dispatcher. ``ctx.auth_info`` and ``ctx.agent`` carry the caller's principal. Adopters MUST gate per-entry persistence by the caller's tenant: each entry's ``account.operator`` (or ``account_id``) must map to the same tenant the auth principal authorizes; otherwise return a row with ``status='failed'`` carrying ``errors=[{code: 'PERMISSION_DENIED', ...}]`` for that entry. Operation-level rejection (``raise AdcpError(...)``) fails the whole batch, which is the wrong shape when a single entry fails the gate. """ ...sync_governanceAPI surface. Optional adopter-side feature.Buyers register governance agent endpoints per-account; the seller persists the binding and consults the agents during media buy lifecycle events via
check_governance. Adopters that don't model buyer-supplied governance agents (most direct sellers) leave this unimplemented and the framework returnsUNSUPPORTED_FEATURE.Ancestors
- typing.Protocol
- typing.Generic
Methods
def sync_governance(self,
entries: list[SyncGovernanceEntry],
ctx: ResolveContext | None = None) ‑> collections.abc.Awaitable[list[SyncGovernanceResultRow]] | list[SyncGovernanceResultRow]-
Expand source code
def sync_governance( self, entries: list[SyncGovernanceEntry], ctx: ResolveContext | None = None, ) -> Awaitable[list[SyncGovernanceResultRow]] | list[SyncGovernanceResultRow]: """Persist the per-entry governance-agent bindings. ``entries`` is the wire request's ``accounts[]`` — each entry pairs an :class:`AccountReference` with its ``governance_agents[]``. The framework has already deduped on ``idempotency_key`` and stripped wire metadata (``adcp_major_version``, ``context``, ``ext``) before invoking this method. **Replace semantics, per spec.** Each call REPLACES the previously synced governance agents for the referenced account. An entry whose ``governance_agents`` is empty clears the binding for that account. **Write-only credentials.** Each ``governance_agents[i].authentication.credentials`` is the bearer the seller presents to that governance agent on outbound ``check_governance`` calls. Persist them — silently dropping ships unauthenticated requests once cross-agent calls are wired. The framework strips ``authentication`` from each ``governance_agents[i]`` of every row before serialization (:func:`to_wire_sync_governance_row`), so credentials never reach the response wire OR the idempotency replay cache, even if an adopter returns a loosely-typed row that spreads the input. Do not rely on Python type hints alone — the strip is enforced at the dispatcher. ``ctx.auth_info`` and ``ctx.agent`` carry the caller's principal. Adopters MUST gate per-entry persistence by the caller's tenant: each entry's ``account.operator`` (or ``account_id``) must map to the same tenant the auth principal authorizes; otherwise return a row with ``status='failed'`` carrying ``errors=[{code: 'PERMISSION_DENIED', ...}]`` for that entry. Operation-level rejection (``raise AdcpError(...)``) fails the whole batch, which is the wrong shape when a single entry fails the gate. """ ...Persist the per-entry governance-agent bindings.
entriesis the wire request'saccounts[]— each entry pairs an :class:AccountReferencewith itsgovernance_agents[]. The framework has already deduped onidempotency_keyand stripped wire metadata (adcp_major_version,context,ext) before invoking this method.Replace semantics, per spec. Each call REPLACES the previously synced governance agents for the referenced account. An entry whose
governance_agentsis empty clears the binding for that account.Write-only credentials. Each
governance_agents[i].authentication.credentialsis the bearer the seller presents to that governance agent on outboundcheck_governancecalls. Persist them — silently dropping ships unauthenticated requests once cross-agent calls are wired. The framework stripsauthenticationfrom eachgovernance_agents[i]of every row before serialization (:func:to_wire_sync_governance_row), so credentials never reach the response wire OR the idempotency replay cache, even if an adopter returns a loosely-typed row that spreads the input. Do not rely on Python type hints alone — the strip is enforced at the dispatcher.ctx.auth_infoandctx.agentcarry the caller's principal. Adopters MUST gate per-entry persistence by the caller's tenant: each entry'saccount.operator(oraccount_id) must map to the same tenant the auth principal authorizes; otherwise return a row withstatus='failed'carryingerrors=[{code: 'PERMISSION_DENIED', ...}]for that entry. Operation-level rejection (raise AdcpError(…)) fails the whole batch, which is the wrong shape when a single entry fails the gate.
class AccountStoreUpsert (*args, **kwargs)-
Expand source code
@runtime_checkable class AccountStoreUpsert(Protocol): """``sync_accounts`` API surface. Optional adopter-side feature that complements :class:`AccountStore.resolve`. Not parameterized over ``TMeta`` — :meth:`upsert` returns :class:`SyncAccountsResultRow` (a wire-shaped row, no per-platform metadata) rather than ``Account[TMeta]``, so the type variable isn't needed here. Adopters implement this on the same object as :class:`AccountStore` (Protocols are structural — Python doesn't require explicit inheritance) and the framework's dispatch shim picks it up via :func:`hasattr`. **Backwards-compatible.** ``ctx`` is optional on the platform side, so adopter impls written before ctx threading landed (no ``ctx`` parameter) keep working — the framework's :func:`_call_with_optional_ctx` shim probes via :func:`inspect.signature` and drops ``ctx`` for pre-ctx impls. """ def upsert( self, refs: list[AccountReference], ctx: ResolveContext | None = None, ) -> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]: """``sync_accounts`` API surface. Framework normalizes the wire request; platform upserts and returns per-account result rows. Raise :class:`adcp.decisioning.AdcpError` for buyer-facing rejection. ``ctx.auth_info`` carries the caller's authenticated principal; ``ctx.agent`` carries the resolved :class:`BuyerAgent` record (when a registry is configured). Adopters implementing principal-keyed gates (e.g., per-buyer-agent ``BILLING_NOT_PERMITTED_FOR_AGENT`` on the spec's billing surfaces) read the principal here — same threading as :meth:`AccountStore.resolve`. **Prefer ``ctx.agent`` over ``ctx.auth_info`` for commercial-relationship decisions.** ``ctx.agent`` is the registry-resolved durable identity (status, billing capabilities, default account terms); ``ctx.auth_info`` carries the raw transport-level credential. For billing gates the registry-resolved identity is canonical. """ ...sync_accountsAPI surface. Optional adopter-side feature that complements :class:AccountStore.resolve().Not parameterized over
TMeta— :meth:upsertreturns :class:SyncAccountsResultRow(a wire-shaped row, no per-platform metadata) rather thanAccount[TMeta], so the type variable isn't needed here.Adopters implement this on the same object as :class:
AccountStore(Protocols are structural — Python doesn't require explicit inheritance) and the framework's dispatch shim picks it up via :func:hasattr.Backwards-compatible.
ctxis optional on the platform side, so adopter impls written before ctx threading landed (noctxparameter) keep working — the framework's :func:_call_with_optional_ctxshim probes via :func:inspect.signatureand dropsctxfor pre-ctx impls.Ancestors
- typing.Protocol
- typing.Generic
Methods
def upsert(self,
refs: list[AccountReference],
ctx: ResolveContext | None = None) ‑> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]-
Expand source code
def upsert( self, refs: list[AccountReference], ctx: ResolveContext | None = None, ) -> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]: """``sync_accounts`` API surface. Framework normalizes the wire request; platform upserts and returns per-account result rows. Raise :class:`adcp.decisioning.AdcpError` for buyer-facing rejection. ``ctx.auth_info`` carries the caller's authenticated principal; ``ctx.agent`` carries the resolved :class:`BuyerAgent` record (when a registry is configured). Adopters implementing principal-keyed gates (e.g., per-buyer-agent ``BILLING_NOT_PERMITTED_FOR_AGENT`` on the spec's billing surfaces) read the principal here — same threading as :meth:`AccountStore.resolve`. **Prefer ``ctx.agent`` over ``ctx.auth_info`` for commercial-relationship decisions.** ``ctx.agent`` is the registry-resolved durable identity (status, billing capabilities, default account terms); ``ctx.auth_info`` carries the raw transport-level credential. For billing gates the registry-resolved identity is canonical. """ ...sync_accountsAPI surface. Framework normalizes the wire request; platform upserts and returns per-account result rows. Raise :class:AdcpErrorfor buyer-facing rejection.ctx.auth_infocarries the caller's authenticated principal;ctx.agentcarries the resolved :class:BuyerAgentrecord (when a registry is configured). Adopters implementing principal-keyed gates (e.g., per-buyer-agentBILLING_NOT_PERMITTED_FOR_AGENTon the spec's billing surfaces) read the principal here — same threading as :meth:AccountStore.resolve().Prefer
ctx.agentoverctx.auth_infofor commercial-relationship decisions.ctx.agentis the registry-resolved durable identity (status, billing capabilities, default account terms);ctx.auth_infocarries the raw transport-level credential. For billing gates the registry-resolved identity is canonical.
class AccountStoreUpsertRequest (*args, **kwargs)-
Expand source code
@runtime_checkable class AccountStoreUpsertRequest(Protocol): """Full-request ``sync_accounts`` API surface. Prefer this over :class:`AccountStoreUpsert` when a store needs request-level fields such as ``push_notification_config``, ``delete_missing``, or ``dry_run``. The framework calls this hook with the parsed :class:`adcp.types.SyncAccountsRequest` and then projects the return value through the same ``sync_accounts`` response path as the legacy ``upsert`` hook. Backwards compatibility: stores that only implement :meth:`AccountStoreUpsert.upsert` continue to receive ``params.accounts`` exactly as before. """ def upsert_request( self, params: SyncAccountsRequest, ctx: ResolveContext | None = None, ) -> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]: """Persist a full ``sync_accounts`` request. Use this hook when request-envelope data must be stored or acted on. ``ctx`` carries the same principal and buyer-agent context as the legacy ``upsert`` path. """ raise NotImplementedErrorFull-request
sync_accountsAPI surface.Prefer this over :class:
AccountStoreUpsertwhen a store needs request-level fields such aspush_notification_config,delete_missing, ordry_run. The framework calls this hook with the parsed :class:SyncAccountsRequestand then projects the return value through the samesync_accountsresponse path as the legacyupserthook.Backwards compatibility: stores that only implement :meth:
AccountStoreUpsert.upsert()continue to receiveparams.accountsexactly as before.Ancestors
- typing.Protocol
- typing.Generic
Methods
def upsert_request(self,
params: SyncAccountsRequest,
ctx: ResolveContext | None = None) ‑> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]-
Expand source code
def upsert_request( self, params: SyncAccountsRequest, ctx: ResolveContext | None = None, ) -> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]: """Persist a full ``sync_accounts`` request. Use this hook when request-envelope data must be stored or acted on. ``ctx`` carries the same principal and buyer-agent context as the legacy ``upsert`` path. """ raise NotImplementedErrorPersist a full
sync_accountsrequest.Use this hook when request-envelope data must be stored or acted on.
ctxcarries the same principal and buyer-agent context as the legacyupsertpath.
class ExplicitAccounts (loader: Callable[[str], Awaitable[Account[TMeta]] | Account[TMeta]])-
Expand source code
class ExplicitAccounts(Generic[TMeta]): """Multi-tenant where the wire ref identifies the account. Use for: salesagent (URL-pattern ``/tenants/<id>/...``), DSPs that expose multi-account-per-principal flows, agencies routing across publisher accounts via ``account.account_id`` in the body. The framework passes ``ref`` from the parsed request body (typically ``request.account``); ``resolve`` reads ``ref["account_id"]`` and routes through the adopter-supplied ``loader``. The wire ref is the source of truth for *which* account to resolve. Auth scope checks (does this principal have access to the requested account?) are NOT performed by ``ExplicitAccounts.resolve`` — the default loader signature only takes ``account_id``. Adopters needing principal-vs-account scope enforcement implement the :class:`AccountStore` Protocol directly with a custom resolve that reads ``auth_info``, OR add a request middleware that runs before the handler. The framework does NOT silently bind ``auth_info`` to the lookup; if your loader returns an account a principal shouldn't see, you've shipped a cross-tenant data leak. Example:: class SalesAgentSeller(DecisioningPlatform): accounts = ExplicitAccounts(loader=load_tenant_from_db) :param loader: Callable taking ``account_id: str`` and returning an :class:`Account` instance. Sync or async. Raises ``AdcpError(code='ACCOUNT_NOT_FOUND')`` on miss. """ resolution: ClassVar[str] = "explicit" def __init__( self, loader: Callable[[str], Awaitable[Account[TMeta]] | Account[TMeta]], ) -> None: self._loader = loader def resolve( self, ref: dict[str, Any] | None, auth_info: AuthInfo | None = None, ) -> Awaitable[Account[TMeta]] | Account[TMeta]: # Explicit mode resolves purely off the wire ref. Adopters # needing principal-vs-account scope checks implement # AccountStore directly (see class docstring). The loader # signature is account_id-only by contract, so auth_info isn't # threaded through here. del auth_info if not ref or not ref.get("account_id"): from adcp.decisioning.types import AdcpError raise AdcpError( "ACCOUNT_NOT_FOUND", message=( "ExplicitAccounts.resolve requires ref with 'account_id'; " "got missing/empty ref" ), recovery="terminal", field="account.account_id", ) return self._loader(ref["account_id"])Multi-tenant where the wire ref identifies the account.
Use for: salesagent (URL-pattern
/tenants/<id>/...), DSPs that expose multi-account-per-principal flows, agencies routing across publisher accounts viaaccount.account_idin the body.The framework passes
reffrom the parsed request body (typicallyrequest.account);resolvereadsref["account_id"]and routes through the adopter-suppliedloader. The wire ref is the source of truth for which account to resolve.Auth scope checks (does this principal have access to the requested account?) are NOT performed by
ExplicitAccounts.resolve()— the default loader signature only takesaccount_id. Adopters needing principal-vs-account scope enforcement implement the :class:AccountStoreProtocol directly with a custom resolve that readsauth_info, OR add a request middleware that runs before the handler. The framework does NOT silently bindauth_infoto the lookup; if your loader returns an account a principal shouldn't see, you've shipped a cross-tenant data leak.Example::
class SalesAgentSeller(DecisioningPlatform): accounts = ExplicitAccounts(loader=load_tenant_from_db):param loader: Callable taking
account_id: strand returning an :class:Accountinstance. Sync or async. RaisesAdcpError(code='ACCOUNT_NOT_FOUND')on miss.Ancestors
- typing.Generic
Class variables
var resolution : ClassVar[str]
Methods
def resolve(self, ref: dict[str, Any] | None, auth_info: AuthInfo | None = None) ‑> Awaitable[Account[~TMeta]] | Account[~TMeta]-
Expand source code
def resolve( self, ref: dict[str, Any] | None, auth_info: AuthInfo | None = None, ) -> Awaitable[Account[TMeta]] | Account[TMeta]: # Explicit mode resolves purely off the wire ref. Adopters # needing principal-vs-account scope checks implement # AccountStore directly (see class docstring). The loader # signature is account_id-only by contract, so auth_info isn't # threaded through here. del auth_info if not ref or not ref.get("account_id"): from adcp.decisioning.types import AdcpError raise AdcpError( "ACCOUNT_NOT_FOUND", message=( "ExplicitAccounts.resolve requires ref with 'account_id'; " "got missing/empty ref" ), recovery="terminal", field="account.account_id", ) return self._loader(ref["account_id"])
class FromAuthAccounts (loader: Callable[[str], Awaitable[Account[TMeta]] | Account[TMeta]])-
Expand source code
class FromAuthAccounts(Generic[TMeta]): """Multi-tenant where the verified auth principal identifies the account. Use for: signed-request-bound integrations (one signing key per publisher account), OAuth-bearer integrations where the token binds to a specific account, MMP / measurement-vendor patterns where the principal IS the account holder. Reads ``auth_info.principal`` and routes through the adopter- supplied ``loader``. The wire ``ref`` is ignored — the auth principal is the source of truth. Example:: class MeasurementVendor(DecisioningPlatform): accounts = FromAuthAccounts(loader=load_account_for_principal) :param loader: Callable taking ``principal: str`` and returning an :class:`Account` instance. Sync or async. """ resolution: ClassVar[str] = "implicit" def __init__( self, loader: Callable[[str], Awaitable[Account[TMeta]] | Account[TMeta]], ) -> None: self._loader = loader def resolve( self, ref: dict[str, Any] | None = None, auth_info: AuthInfo | None = None, ) -> Awaitable[Account[TMeta]] | Account[TMeta]: del ref # from_auth ignores wire refs if auth_info is None or not auth_info.principal: from adcp.decisioning.types import AdcpError raise AdcpError( "AUTH_INVALID", message=( "FromAuthAccounts.resolve requires auth_info with a " "verified principal; got None / empty" ), recovery="terminal", ) return self._loader(auth_info.principal)Multi-tenant where the verified auth principal identifies the account.
Use for: signed-request-bound integrations (one signing key per publisher account), OAuth-bearer integrations where the token binds to a specific account, MMP / measurement-vendor patterns where the principal IS the account holder.
Reads
auth_info.principaland routes through the adopter- suppliedloader. The wirerefis ignored — the auth principal is the source of truth.Example::
class MeasurementVendor(DecisioningPlatform): accounts = FromAuthAccounts(loader=load_account_for_principal):param loader: Callable taking
principal: strand returning an :class:Accountinstance. Sync or async.Ancestors
- typing.Generic
Class variables
var resolution : ClassVar[str]
Methods
def resolve(self, ref: dict[str, Any] | None = None, auth_info: AuthInfo | None = None) ‑> Awaitable[Account[~TMeta]] | Account[~TMeta]-
Expand source code
def resolve( self, ref: dict[str, Any] | None = None, auth_info: AuthInfo | None = None, ) -> Awaitable[Account[TMeta]] | Account[TMeta]: del ref # from_auth ignores wire refs if auth_info is None or not auth_info.principal: from adcp.decisioning.types import AdcpError raise AdcpError( "AUTH_INVALID", message=( "FromAuthAccounts.resolve requires auth_info with a " "verified principal; got None / empty" ), recovery="terminal", ) return self._loader(auth_info.principal)
class ResolveContext (auth_info: AuthInfo | None = None,
tool_name: str | None = None,
agent: BuyerAgent | None = None,
extra: dict[str, Any] = <factory>)-
Expand source code
@dataclass class ResolveContext: """Per-request context threaded into :class:`AccountStore` methods that need the caller's principal but don't get a full :class:`RequestContext` (because the resolved account isn't yet available, or the surface operates on multiple accounts at once). Mirrors the JS-side ``ResolveContext`` shape: ``auth_info``, ``tool_name``, ``agent``. Adopters read these to implement principal-keyed gates on ``sync_accounts`` / ``list_accounts`` / ``sync_governance`` (e.g., the spec's ``BILLING_NOT_PERMITTED_FOR_AGENT`` per-buyer-agent gate from adcontextprotocol/adcp#3851) without re-deriving identity from the request. **Prefer ``agent`` over ``auth_info`` for commercial-relationship decisions.** ``agent`` is the registry-resolved durable identity (status, billing capabilities, default account terms); ``auth_info`` is the raw transport-level credential. For billing gates the registry-resolved identity is canonical. :param auth_info: Verified principal info. ``None`` for unauthenticated requests (dev / ``'derived'`` fixtures). :param tool_name: Wire verb that triggered the call (e.g. ``'sync_accounts'``, ``'list_accounts'``, ``'sync_governance'``). Adopters use it for audit logs; the framework doesn't dispatch on it. :param agent: Resolved :class:`BuyerAgent` when a :class:`BuyerAgentRegistry` is wired. ``None`` otherwise. """ auth_info: AuthInfo | None = None tool_name: str | None = None agent: BuyerAgent | None = None #: Adopter passthrough for additional context the framework #: doesn't model. Reserved for forward compatibility. extra: dict[str, Any] = field(default_factory=dict)Per-request context threaded into :class:
AccountStoremethods that need the caller's principal but don't get a full :class:RequestContext(because the resolved account isn't yet available, or the surface operates on multiple accounts at once).Mirrors the JS-side
ResolveContextshape:auth_info,tool_name,agent. Adopters read these to implement principal-keyed gates onsync_accounts/list_accounts/sync_governance(e.g., the spec'sBILLING_NOT_PERMITTED_FOR_AGENTper-buyer-agent gate from adcontextprotocol/adcp#3851) without re-deriving identity from the request.Prefer
agentoverauth_infofor commercial-relationship decisions.agentis the registry-resolved durable identity (status, billing capabilities, default account terms);auth_infois the raw transport-level credential. For billing gates the registry-resolved identity is canonical.:param auth_info: Verified principal info.
Nonefor unauthenticated requests (dev /'derived'fixtures). :param tool_name: Wire verb that triggered the call (e.g.'sync_accounts','list_accounts','sync_governance'). Adopters use it for audit logs; the framework doesn't dispatch on it. :param agent: Resolved :class:BuyerAgentwhen a :class:BuyerAgentRegistryis wired.Noneotherwise.Instance variables
var agent : BuyerAgent | Nonevar auth_info : AuthInfo | Nonevar extra : dict[str, Any]-
Adopter passthrough for additional context the framework doesn't model. Reserved for forward compatibility.
var tool_name : str | None
class SingletonAccounts (account_id: str,
*,
name: str = '',
metadata_factory: Callable[[], TMeta] | None = None,
mode: "Literal['live', 'sandbox', 'mock'] | None" = None)-
Expand source code
class SingletonAccounts(Generic[TMeta]): """Single-platform deployment with per-principal idempotency scoping. Use for: Innovid training-agent class, single-publisher proof-of- concepts, dev/staging environments. Synthesizes ``account.id`` from the verified principal: ``f"{base_account_id}:{principal}"``. Without this, every caller across the entire deployment would share one idempotency cache — UUID collision (random or engineered) returns another caller's ``response_payload``, which is a buyer-to-buyer data leak. Per-principal synthesis closes this while keeping the "one platform, no per-tenant lookup" ergonomic. For unauthenticated dev fixtures (``ctx.auth_info is None``), the synthesized id is ``f"{base_account_id}:anonymous"`` — adopters relying on this MUST ensure their dev/CI pipeline authenticates before any cross-test isolation matters. Example:: class TrainingAgentSeller(DecisioningPlatform): accounts = SingletonAccounts(account_id="training-agent") :param account_id: Base account id used in the synthesized per-principal id. Must be stable across process restarts so idempotency cache hits work across deploys. :param name: Human-readable name copied to ``Account.name``. :param metadata_factory: Optional factory for ``Account.metadata`` — adopters with typed metadata pass a closure that returns the right TypedDict / dataclass instance. :param mode: Optional account-mode flag. When set, every resolved :class:`Account` carries ``mode`` and is stamped explicit so the framework's observed-modes tracker counts the resolution. Default ``None`` — leaves accounts at the implicit-default ``'live'`` (pre-mode behavior). Pass ``'sandbox'`` for a single-platform conformance / dev deployment that should admit ``comply_test_controller``; pass ``'live'`` to deliberately mark the singleton as production (the env-fallback guard then trips loudly if ``ADCP_SANDBOX=1`` is also set). """ resolution: ClassVar[str] = "derived" def __init__( self, account_id: str, *, name: str = "", metadata_factory: Callable[[], TMeta] | None = None, mode: Literal["live", "sandbox", "mock"] | None = None, ) -> None: if not account_id or not isinstance(account_id, str): raise ValueError( f"SingletonAccounts requires a non-empty account_id; got {account_id!r}" ) self._account_id = account_id self._name = name or account_id self._metadata_factory = metadata_factory self._mode = mode def resolve( self, ref: dict[str, Any] | None = None, auth_info: AuthInfo | None = None, ) -> Account[TMeta]: del ref # singleton ignores wire refs principal = auth_info.principal if auth_info and auth_info.principal else "anonymous" scoped_id = f"{self._account_id}:{principal}" metadata: TMeta = ( self._metadata_factory() if self._metadata_factory else {} # type: ignore[assignment] ) kwargs: dict[str, Any] = {} if self._mode is not None: # Adopter passed explicit mode at construction — stamp it on # the Account AND mark explicit so the observed-modes tracker # counts this resolution for the env-fallback fail-closed # guard. kwargs["mode"] = self._mode kwargs["_mode_explicit"] = True return Account( id=scoped_id, name=f"{self._name} ({principal})" if principal != "anonymous" else self._name, status="active", metadata=metadata, auth_info=_auth_info_to_dict(auth_info), **kwargs, )Single-platform deployment with per-principal idempotency scoping.
Use for: Innovid training-agent class, single-publisher proof-of- concepts, dev/staging environments.
Synthesizes
account.idfrom the verified principal:f"{base_account_id}:{principal}". Without this, every caller across the entire deployment would share one idempotency cache — UUID collision (random or engineered) returns another caller'sresponse_payload, which is a buyer-to-buyer data leak. Per-principal synthesis closes this while keeping the "one platform, no per-tenant lookup" ergonomic.For unauthenticated dev fixtures (
ctx.auth_info is None), the synthesized id isf"{base_account_id}:anonymous"— adopters relying on this MUST ensure their dev/CI pipeline authenticates before any cross-test isolation matters.Example::
class TrainingAgentSeller(DecisioningPlatform): accounts = SingletonAccounts(account_id="training-agent"):param account_id: Base account id used in the synthesized per-principal id. Must be stable across process restarts so idempotency cache hits work across deploys. :param name: Human-readable name copied to
Account.name. :param metadata_factory: Optional factory forAccount.metadata— adopters with typed metadata pass a closure that returns the right TypedDict / dataclass instance. :param mode: Optional account-mode flag. When set, every resolved :class:Accountcarriesmodeand is stamped explicit so the framework's observed-modes tracker counts the resolution. DefaultNone— leaves accounts at the implicit-default'live'(pre-mode behavior). Pass'sandbox'for a single-platform conformance / dev deployment that should admitcomply_test_controller; pass'live'to deliberately mark the singleton as production (the env-fallback guard then trips loudly ifADCP_SANDBOX=1is also set).Ancestors
- typing.Generic
Class variables
var resolution : ClassVar[str]
Methods
def resolve(self, ref: dict[str, Any] | None = None, auth_info: AuthInfo | None = None) ‑> Account[~TMeta]-
Expand source code
def resolve( self, ref: dict[str, Any] | None = None, auth_info: AuthInfo | None = None, ) -> Account[TMeta]: del ref # singleton ignores wire refs principal = auth_info.principal if auth_info and auth_info.principal else "anonymous" scoped_id = f"{self._account_id}:{principal}" metadata: TMeta = ( self._metadata_factory() if self._metadata_factory else {} # type: ignore[assignment] ) kwargs: dict[str, Any] = {} if self._mode is not None: # Adopter passed explicit mode at construction — stamp it on # the Account AND mark explicit so the observed-modes tracker # counts this resolution for the env-fallback fail-closed # guard. kwargs["mode"] = self._mode kwargs["_mode_explicit"] = True return Account( id=scoped_id, name=f"{self._name} ({principal})" if principal != "anonymous" else self._name, status="active", metadata=metadata, auth_info=_auth_info_to_dict(auth_info), **kwargs, )