Skip to content

Commit 347de14

Browse files
bokelleyclaude
andcommitted
fix(decisioning): wire sync_accounts/list_accounts dispatch to AccountStore Protocols
PlatformHandler.sync_accounts and PlatformHandler.list_accounts were inherited as _not_supported stubs from ADCPHandler — every adopter implementing AccountStoreUpsert / AccountStoreList saw their account roster invisible on the wire regardless of what the store declared. This wires real shims that route through platform.accounts.upsert / .list with a ResolveContext carrying the caller's verified AuthInfo and registry-resolved BuyerAgent (same threading AccountStore.resolve already uses, so adopter impls implementing principal-keyed gates — e.g. spec BILLING_NOT_PERMITTED_FOR_AGENT — read the principal off the canonical context). Wire advertisement: _ACCOUNT_ADVERTISED_TOOLS = {"sync_accounts", "list_accounts"} unioned into every sales-* entry of SPECIALISM_TO_ADVERTISED_TOOLS. The per-instance filter at advertised_tools_for_instance drops the tool when the store doesn't expose the corresponding optional Protocol method (with a one-line logger.info breadcrumb on first drop), so adopters who haven't wired the optional Protocols don't over-advertise. Defense-in-depth: shims project results through strip_credentials_from_wire_result on the final envelope so loose dicts and Pydantic extra='allow' rows can't smuggle governance_agents[i].authentication or billing_entity.bank past the typed projections. Closes #609. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a712ae commit 347de14

4 files changed

Lines changed: 756 additions & 14 deletions

File tree

docs/handler-authoring.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,108 @@ For the full set of scope invariants — what each field means, how
738738
cache keys are composed, what leaks if you populate fields wrong — see
739739
[docs/multi-tenant-contract.md](./multi-tenant-contract.md).
740740

741+
## Account roster: `sync_accounts` and `list_accounts`
742+
743+
Sales-* adopters expose two account-roster tools so buyers can
744+
declare implicit accounts (`sync_accounts`) or discover explicit ones
745+
(`list_accounts`). Both dispatch through optional Protocols on
746+
your `AccountStore`, not through per-specialism platform methods —
747+
implement them on the same object you use for `AccountStore.resolve`.
748+
749+
```python
750+
from adcp.decisioning import (
751+
AccountStore,
752+
DecisioningPlatform,
753+
ResolveContext,
754+
)
755+
from adcp.decisioning.types import Account, SyncAccountsResultRow
756+
757+
758+
class TenantAccountStore:
759+
resolution = "explicit"
760+
761+
def resolve(self, ref, auth_info=None):
762+
return self._load(ref["account_id"])
763+
764+
# Optional — opts your platform into ``sync_accounts``.
765+
async def upsert(
766+
self,
767+
refs: list,
768+
ctx: ResolveContext | None = None,
769+
) -> list[SyncAccountsResultRow]:
770+
principal = ctx.auth_info.principal if ctx and ctx.auth_info else None
771+
return [
772+
SyncAccountsResultRow(
773+
brand=ref.brand.model_dump(),
774+
operator=ref.operator,
775+
action="created",
776+
status="active",
777+
account_id=self._provision(ref, principal),
778+
)
779+
for ref in refs
780+
]
781+
782+
# Optional — opts your platform into ``list_accounts``.
783+
async def list(
784+
self,
785+
filter: dict | None = None,
786+
ctx: ResolveContext | None = None,
787+
) -> list[Account]:
788+
principal = ctx.auth_info.principal if ctx and ctx.auth_info else None
789+
return self._list_for_principal(principal, filter or {})
790+
791+
792+
class MySeller(DecisioningPlatform):
793+
capabilities = ... # your sales-* claim
794+
accounts = TenantAccountStore()
795+
```
796+
797+
**The framework auto-advertises `sync_accounts` / `list_accounts`
798+
for every `sales-*` claim.** When your store doesn't expose `upsert`
799+
or `list`, the per-instance advertisement filter drops the missing
800+
tool from `tools/list` AND emits a one-line `logger.info` at first
801+
boot:
802+
803+
```
804+
PlatformHandler dropped 'sync_accounts' from advertised_tools — platform.accounts
805+
does not implement 'upsert'. Implement the optional AccountStoreUpsert Protocol
806+
method (see adcp.decisioning.accounts) to surface this tool on the wire.
807+
```
808+
809+
If a buyer somehow calls a tool you didn't advertise, the shim
810+
returns `NotImplementedResponse(supported=False)` — never an
811+
`AttributeError`.
812+
813+
**Filter shape for `list`.** The framework projects
814+
`ListAccountsRequest` to a flat dict before calling your store:
815+
`status` (string, e.g. `"active"`), `sandbox` (bool), and
816+
`pagination` (`{"max_results": 50, "cursor": "..."}` — already
817+
`model_dump`-ed, never the typed Pydantic instance). `None` values
818+
are stripped, so you can pattern-match present-vs-absent without
819+
explicit `None` checks.
820+
821+
**`ResolveContext` carries the principal.** `ctx.auth_info` is the
822+
verified credential; `ctx.agent` is the registry-resolved
823+
`BuyerAgent` when you've wired a `BuyerAgentRegistry` (preferred for
824+
commercial-relationship gates like
825+
`BILLING_NOT_PERMITTED_FOR_AGENT`). `ctx.tool_name` is
826+
`'sync_accounts'` / `'list_accounts'` for audit logs.
827+
828+
**Wire-envelope wrapping is automatic.** Return
829+
`list[SyncAccountsResultRow]` / `list[Account]` and the framework
830+
projects each row through `to_wire_sync_accounts_row` /
831+
`to_wire_account` (write-only credential strip on
832+
`billing_entity.bank` and `governance_agents[i].authentication`)
833+
and wraps the result as `{"accounts": [...]}` for the wire. Don't
834+
build the envelope yourself — the framework already does it, AND
835+
runs a defense-in-depth credential scrub on the final payload.
836+
837+
For roster-curated stores that reject `sync_accounts` outright (the
838+
adopter manages accounts out-of-band), see
839+
`adcp.decisioning.create_roster_account_store`. For multi-tenant
840+
stores enforcing per-entry tenant isolation, see
841+
`adcp.decisioning.create_tenant_account_store`.
842+
741843
## Account modes and mock-mode upstream routing
742844

743845
The framework recognizes three operationally distinct account modes

src/adcp/decisioning/accounts.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,25 @@ def list(
280280
) -> Awaitable[list[Account[TMeta]]] | list[Account[TMeta]]:
281281
"""Return the accounts visible to the calling principal.
282282
283-
:param filter: Wire-shape filter object — ``status`` /
284-
``sandbox`` / pagination. Pass-through from the parsed
285-
wire request.
283+
:param filter: Wire-shape filter dict projected from the parsed
284+
``ListAccountsRequest`` by the framework's
285+
:func:`adcp.decisioning.handler._build_list_accounts_filter`.
286+
Keys (all optional, omitted when not set on the wire):
287+
288+
* ``status`` (``str``) — one of ``'active'``,
289+
``'pending_approval'``, ``'rejected'``, ``'payment_required'``,
290+
``'suspended'``, ``'closed'``. Already coerced from the
291+
codegen'd Enum to its string ``.value``.
292+
* ``sandbox`` (``bool``) — sandbox-account marker.
293+
* ``pagination`` (``dict``) — sub-keys are
294+
``max_results: int`` (1–100, default 50) and
295+
``cursor: str`` (opaque, from a prior response). Already
296+
``model_dump(mode='json', exclude_none=True)``-ed; never
297+
the typed Pydantic instance.
298+
299+
The framework strips ``None`` values before invoking, so
300+
adopters can pattern-match present-vs-absent without
301+
explicit ``None`` checks.
286302
:param ctx: Per-request context. ``ctx.auth_info`` and
287303
``ctx.agent`` carry the caller's principal — adopters
288304
scope the listing per-principal (e.g., return only

0 commit comments

Comments
 (0)