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
107 changes: 107 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,113 @@ For the full set of scope invariants — what each field means, how
cache keys are composed, what leaks if you populate fields wrong — see
[docs/multi-tenant-contract.md](./multi-tenant-contract.md).

## Account roster: `sync_accounts` and `list_accounts`

Sales-* adopters expose two account-roster tools so buyers can
declare implicit accounts (`sync_accounts`) or discover explicit ones
(`list_accounts`). Both dispatch through optional Protocols on
your `AccountStore`, not through per-specialism platform methods —
implement them on the same object you use for `AccountStore.resolve`.

```python
from adcp.decisioning import (
AccountStore,
DecisioningPlatform,
ResolveContext,
)
from adcp.decisioning.types import Account, SyncAccountsResultRow


class TenantAccountStore:
resolution = "explicit"

def resolve(self, ref, auth_info=None):
return self._load(ref["account_id"])

# Optional — opts your platform into ``sync_accounts``.
async def upsert(
self,
refs: list,
ctx: ResolveContext | None = None,
) -> list[SyncAccountsResultRow]:
principal = ctx.auth_info.principal if ctx and ctx.auth_info else None
return [
SyncAccountsResultRow(
brand=ref.brand.model_dump(),
operator=ref.operator,
action="created",
status="active",
account_id=self._provision(ref, principal),
)
for ref in refs
]

# Optional — opts your platform into ``list_accounts``.
# Note: the framework calls this with the parameter named ``filter``
# (matching the Protocol signature). Bind it to ``filter_`` inside
# your impl so you don't shadow the ``filter`` builtin in the
# method body.
async def list(
self,
filter: dict | None = None, # noqa: A002 — Protocol param name
ctx: ResolveContext | None = None,
) -> list[Account]:
filter_ = filter or {}
principal = ctx.auth_info.principal if ctx and ctx.auth_info else None
return self._list_for_principal(principal, filter_)


class MySeller(DecisioningPlatform):
capabilities = ... # your sales-* claim
accounts = TenantAccountStore()
```

**The framework auto-advertises `sync_accounts` / `list_accounts`
for every `sales-*` claim.** When your store doesn't expose `upsert`
or `list`, the per-instance advertisement filter drops the missing
tool from `tools/list` AND emits a one-line `logger.info` at first
boot:

```
PlatformHandler dropped 'sync_accounts' from advertised_tools — platform.accounts
does not implement 'upsert'. Implement the optional AccountStoreUpsert Protocol
method (see adcp.decisioning.accounts) to surface this tool on the wire.
```

If a buyer somehow calls a tool you didn't advertise, the shim
returns `NotImplementedResponse(supported=False)` — never an
`AttributeError`.

**Filter shape for `list`.** The framework projects
`ListAccountsRequest` to a flat dict before calling your store:
`status` (string, e.g. `"active"`), `sandbox` (bool), and
`pagination` (`{"max_results": 50, "cursor": "..."}` — already
`model_dump`-ed, never the typed Pydantic instance). `None` values
are stripped, so you can pattern-match present-vs-absent without
explicit `None` checks.

**`ResolveContext` carries the principal.** `ctx.auth_info` is the
verified credential; `ctx.agent` is the registry-resolved
`BuyerAgent` when you've wired a `BuyerAgentRegistry` (preferred for
commercial-relationship gates like
`BILLING_NOT_PERMITTED_FOR_AGENT`). `ctx.tool_name` is
`'sync_accounts'` / `'list_accounts'` for audit logs.

**Wire-envelope wrapping is automatic.** Return
`list[SyncAccountsResultRow]` / `list[Account]` and the framework
projects each row through `to_wire_sync_accounts_row` /
`to_wire_account` (write-only credential strip on
`billing_entity.bank` and `governance_agents[i].authentication`)
and wraps the result as `{"accounts": [...]}` for the wire. Don't
build the envelope yourself — the framework already does it, AND
runs a defense-in-depth credential scrub on the final payload.

For roster-curated stores that reject `sync_accounts` outright (the
adopter manages accounts out-of-band), see
`adcp.decisioning.create_roster_account_store`. For multi-tenant
stores enforcing per-entry tenant isolation, see
`adcp.decisioning.create_tenant_account_store`.

## Account modes and mock-mode upstream routing

The framework recognizes three operationally distinct account modes
Expand Down
21 changes: 10 additions & 11 deletions examples/sales_proposal_mode_seller/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ def upsert(
ctx: Any = None,
) -> list[dict[str, Any]]:
"""``sync_accounts`` API. Storyboards call this first to seed
the stateful account chain. Returns one result row per ref."""
the stateful account chain. Returns one result row per ref in
the wire shape per ``schemas/cache/account/sync-accounts-response.json``
— the framework wraps the list as ``{"accounts": [...]}``."""
del ctx
rows: list[dict[str, Any]] = []
for ref in refs:
Expand All @@ -95,16 +97,13 @@ def upsert(
account_id = f"acct_{operator}".replace(".", "_")
rows.append(
{
"ref": ref_dict,
"account": {
"account_id": account_id,
"name": f"Account for {domain or operator}",
"status": "active",
"brand": {"domain": domain or "demo.example"},
"operator": operator,
"billing": "operator",
},
"operation": "created",
"account_id": account_id,
"name": f"Account for {domain or operator}",
"brand": {"domain": domain or "demo.example"},
"operator": operator,
"action": "created",
"status": "active",
"billing": "operator",
}
)
return rows
Expand Down
22 changes: 19 additions & 3 deletions src/adcp/decisioning/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,25 @@ def list(
) -> 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 filter: Wire-shape filter dict projected from the parsed
``ListAccountsRequest`` by the framework's
:func:`adcp.decisioning.handler._build_list_accounts_filter`.
Keys (all optional, omitted when not set on the wire):

* ``status`` (``str``) — one of ``'active'``,
``'pending_approval'``, ``'rejected'``, ``'payment_required'``,
``'suspended'``, ``'closed'``. Already coerced from the
codegen'd Enum to its string ``.value``.
* ``sandbox`` (``bool``) — sandbox-account marker.
* ``pagination`` (``dict``) — sub-keys are
``max_results: int`` (1–100, default 50) and
``cursor: str`` (opaque, from a prior response). Already
``model_dump(mode='json', exclude_none=True)``-ed; never
the typed Pydantic instance.

The framework strips ``None`` values before invoking, so
adopters can pattern-match present-vs-absent without
explicit ``None`` checks.
: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
Expand Down
Loading
Loading