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
39 changes: 39 additions & 0 deletions docs/accounts-ensure-and-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,42 @@ A change to `billing`, `billing_entity` or `payment_terms` needs the explicit ac
## Cold start

`list_accounts` with `include_requests` reads back your open requests and their pending actions. `refresh()` uses `account_request_ids`.

## Serving `request_account` (sellers)

On the decisioning platform, an `AccountStore` serves AdCP 3.3 `account.requests` with two optional hooks:

```python
from adcp.decisioning import AccountRequestsPage, DecisioningCapabilities
from adcp.decisioning.capabilities import Account as AccountCaps

class MyAccounts:
def resolve(self, ref, auth_info=None): ...
def list(self, filter=None, ctx=None): ... # Account(..., revision=3) serves revision

async def request_account(self, params, ctx=None):
# Open, match or change; return the wire AccountRequest record.
return await db.request(params, caller=ctx.agent)

async def list_requests(self, params, ctx=None):
# Only when the buyer sends include_requests, request_ids or requests_pagination.
page = await db.requests_for(ctx.agent, ids=params.request_ids,
cursor=params.requests_pagination and params.requests_pagination.cursor)
return AccountRequestsPage(items=page.records, next_cursor=page.next)

capabilities = DecisioningCapabilities(
specialisms=["sales-non-guaranteed"],
account=AccountCaps(
supported_billing=["operator"],
# Optional, advisory: what change requests accept and which pending actions you issue.
requests={"supported": True, "changeable_attributes": ["billing", "payment_terms", "timezone"],
"pending_action_kinds": ["review", "accept_terms"]},
),
)
```

- **Advertised for you.** With `request_account`, the handler serves the tool and advertises `account.requests` on `get_adcp_capabilities`: `supported: true`, `request_task: "request_account"`, `read_task: "list_accounts"`, plus your optional lists. It also adds `account.requests` to `experimental_features`. Callers on releases before 3.3 see neither, and an `experimental_features` claim without the hook is dropped. Buyers route `ensure` and `change` by this block.
- **`supported_billing` is yours to state.** The account block requires it, and the framework never invents one: a store with `request_account` and no `account.supported_billing` is refused at construction (`ConfigurationError`).
- **Return values.** `request_account` returns the request record, which is served as `{"status": "completed", "request": ...}`. `in_review`, `ambiguous` and `rejected` are normal outcomes. Raise `AdcpError` only for calls you cannot process. A change names `params.account_id`: check that the caller may change that account, and refuse a money change (`billing`, `billing_entity`, `payment_terms`) without `revision`.
- **Polling.** `list_requests` serves `account_requests[]` and `account_requests_pagination` on `list_accounts`. It runs only when the buyer asks, and only for 3.3 callers. Return only the caller's requests, newest `updated_at` first, at most 100 per page, and omit unknown or foreign `request_ids` silently. It needs `list`. With `request_account` but no `list_requests`, the handler warns at construction that buyers cannot poll a pending request.
- **`revision`.** `Account.revision` is served on `list_accounts` to 3.2+ callers. Buyers send it with a change.
6 changes: 6 additions & 0 deletions src/adcp/decisioning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ def create_media_buy(
to_wire_sync_governance_row,
)
from adcp.decisioning.accounts import (
AccountRequestsPage,
AccountStore,
AccountStoreList,
AccountStoreListRequests,
AccountStoreRequestAccount,
AccountStoreResolveForTask,
AccountStoreSyncGovernance,
AccountStoreUpsert,
Expand Down Expand Up @@ -382,8 +385,11 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"Account",
"AccountMode",
"AccountNotFoundError",
"AccountRequestsPage",
"AccountStore",
"AccountStoreList",
"AccountStoreListRequests",
"AccountStoreRequestAccount",
"AccountStoreSyncGovernance",
"AccountStoreUpsert",
"AccountStoreUpsertRequest",
Expand Down
3 changes: 3 additions & 0 deletions src/adcp/decisioning/account_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@ def to_wire_account(account: DecisioningAccount[Any]) -> dict[str, Any]:
wire["timezone"] = account.timezone
if account.operator is not None:
wire["operator"] = account.operator
revision = getattr(account, "revision", None)
if revision is not None:
wire["revision"] = revision
return wire


Expand Down
98 changes: 97 additions & 1 deletion src/adcp/decisioning/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@

if TYPE_CHECKING:
from adcp.decisioning.registry import BuyerAgent
from adcp.types import AccountReference, SyncAccountsRequest
from adcp.types import (
AccountReference,
ListAccountsRequest,
RequestAccountRequest,
SyncAccountsRequest,
)

#: Per-platform metadata generic.
TMeta = TypeVar("TMeta", default=dict[str, Any])
Expand Down Expand Up @@ -400,6 +405,97 @@ def list(
...


@dataclass
class AccountRequestsPage:
"""One page of the caller's account requests, returned by
:meth:`AccountStoreListRequests.list_requests`.

The framework serves ``items`` as ``account_requests[]`` on
``list_accounts`` and builds ``account_requests_pagination`` from
``next_cursor`` (``has_more`` is ``next_cursor is not None``) and
``total_count``. Items are wire ``AccountRequest`` records, as dicts
or Pydantic models.
"""

items: list[Any]
next_cursor: str | None = None
total_count: int | None = None


@runtime_checkable
class AccountStoreRequestAccount(Protocol):
"""``request_account`` API surface (AdCP 3.3, experimental ``account.requests``).

Implementing it serves the ``request_account`` tool and advertises
``account.requests`` on ``get_adcp_capabilities`` (``supported: true``,
``request_task: "request_account"``, ``read_task: "list_accounts"``, and
``account.requests`` in ``experimental_features``) to callers on 3.3 or
later. Optional lists in the block (``changeable_attributes``,
``pending_action_kinds``, ``event_type``) come from
``DecisioningCapabilities.account.requests``. The block needs
``account.supported_billing``: the framework never invents billing
claims, so a store with this hook and no declared ``supported_billing``
is refused at construction.

Pair it with :class:`AccountStoreListRequests`: buyers poll a pending
request through ``list_accounts``.
"""

def request_account(
self,
params: RequestAccountRequest,
ctx: ResolveContext | None = None,
) -> Awaitable[Any] | Any:
"""Open, match or change an account; return the wire ``AccountRequest``.

Return the request record (a dict or model with ``request_id``,
``state``, ``requested``, ...); the framework serves it as
``{"status": "completed", "request": ...}``. A full response dict
with a ``request`` key passes through. An ``in_review``,
``ambiguous`` or ``rejected`` request is a normal outcome, not an
error. Raise :class:`adcp.decisioning.AdcpError` only for a call
you cannot process (``INVALID_REQUEST``, a stale ``revision`` as
``CONFLICT``, an unsupported change as ``UNSUPPORTED_FEATURE``).

A change names ``params.account_id``: check that the caller
(``ctx.agent`` / ``ctx.auth_info``) may change that account, as
:meth:`AccountStore.resolve` does, and refuse a money change
(``billing``, ``billing_entity``, ``payment_terms``) without
``revision``. Idempotency on ``params.idempotency_key`` is the
framework's, as for every mutating task.
"""
...


@runtime_checkable
class AccountStoreListRequests(Protocol):
"""The caller's account requests on ``list_accounts`` (AdCP 3.3, experimental).

Called only when the buyer asks for them, with ``include_requests``,
``request_ids`` or ``requests_pagination``, and only for callers on
3.3 or later. The accounts page is unaffected. Requires
:class:`AccountStoreList`.
"""

def list_requests(
self,
params: ListAccountsRequest,
ctx: ResolveContext | None = None,
) -> Awaitable[AccountRequestsPage | list[Any]] | AccountRequestsPage | list[Any]:
"""Return the caller's requests, newest ``updated_at`` first.

Return only the caller's own requests (identify the caller from
``ctx.agent`` / ``ctx.auth_info``, as for ``list``): every open
request (``in_review``, ``ambiguous``) plus requests that changed
state in the last 30 days, at most 100 per page. With
``params.request_ids``, return only those, omitting unknown and
other callers' ids silently. Page from
``params.requests_pagination.cursor``. A plain list is one
complete page.
"""
...


@runtime_checkable
class AccountStoreSyncGovernance(Protocol):
"""``sync_governance`` API surface. Optional adopter-side feature.
Expand Down
Loading
Loading