Skip to content

Commit dabf4fb

Browse files
bokelleyclaude
andauthored
fix(decisioning): wire sync_accounts/list_accounts dispatch to AccountStore Protocols (#610)
* 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> * fix(examples): sales-proposal-mode seller emits spec-shape sync_accounts rows The example's _SingleTenantAccounts.upsert returned rows shaped as ``{ref, account, operation}`` — a custom layout that worked while the framework's sync_accounts dispatch was a no_supported stub but trips schema output validation now that the dispatch actually invokes accounts.upsert. Reshape each row to the wire schema per schemas/cache/account/sync-accounts-response.json: ``{account_id, brand, operator, action, status, ...}``. The framework wraps the list as ``{"accounts": [...]}``. Verified end-to-end against the running seller: storyboard runner's sync_accounts step now passes output validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(decisioning): unblock sync_accounts/list_accounts bootstrap on explicit-mode stores The shims called ``await self._resolve_account(None, tool_ctx)`` purely to side-effect-stash the registry-resolved buyer-agent on ``ctx.metadata``. For ``AccountStore.resolution == "explicit"`` impls without a principal/subdomain fallback, that no-ref resolve raised ``ACCOUNT_NOT_FOUND`` — deadlocking the bootstrap path that uses ``sync_accounts`` to populate the store in the first place. Extract the side-effect into a ``_prime_auth_context`` helper that runs the buyer-agent registry gate (suspended/blocked rejection still fires) WITHOUT calling ``AccountStore.resolve``. Use it in both account-roster shims; ``_resolve_account`` delegates to it for the buyer-agent stash. Also: - Warn on unexpected ``upsert`` / ``list`` return shapes (None, tuple, bare string) so adopter contract violations aren't silent — the credential scrubber relies on dict-or-list shape. - Document that the per-tenant handler created by ``LazyPlatformRouter`` emits the dropped-tool boot log per (tenant, tool) — intentional, since stores can be wired differently per tenant. - Doc nit: ``AccountStoreList.list`` example shows binding ``filter`` to ``filter_`` to avoid shadowing the builtin in the method body. Test additions: - explicit-mode bootstrap regression: shim must NOT call ``ExplicitAccounts.resolve(None, ...)`` first. - unexpected-shape return triggers the new ``logger.warning``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a712ae commit dabf4fb

5 files changed

Lines changed: 943 additions & 30 deletions

File tree

docs/handler-authoring.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,113 @@ 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+
# Note: the framework calls this with the parameter named ``filter``
784+
# (matching the Protocol signature). Bind it to ``filter_`` inside
785+
# your impl so you don't shadow the ``filter`` builtin in the
786+
# method body.
787+
async def list(
788+
self,
789+
filter: dict | None = None, # noqa: A002 — Protocol param name
790+
ctx: ResolveContext | None = None,
791+
) -> list[Account]:
792+
filter_ = filter or {}
793+
principal = ctx.auth_info.principal if ctx and ctx.auth_info else None
794+
return self._list_for_principal(principal, filter_)
795+
796+
797+
class MySeller(DecisioningPlatform):
798+
capabilities = ... # your sales-* claim
799+
accounts = TenantAccountStore()
800+
```
801+
802+
**The framework auto-advertises `sync_accounts` / `list_accounts`
803+
for every `sales-*` claim.** When your store doesn't expose `upsert`
804+
or `list`, the per-instance advertisement filter drops the missing
805+
tool from `tools/list` AND emits a one-line `logger.info` at first
806+
boot:
807+
808+
```
809+
PlatformHandler dropped 'sync_accounts' from advertised_tools — platform.accounts
810+
does not implement 'upsert'. Implement the optional AccountStoreUpsert Protocol
811+
method (see adcp.decisioning.accounts) to surface this tool on the wire.
812+
```
813+
814+
If a buyer somehow calls a tool you didn't advertise, the shim
815+
returns `NotImplementedResponse(supported=False)` — never an
816+
`AttributeError`.
817+
818+
**Filter shape for `list`.** The framework projects
819+
`ListAccountsRequest` to a flat dict before calling your store:
820+
`status` (string, e.g. `"active"`), `sandbox` (bool), and
821+
`pagination` (`{"max_results": 50, "cursor": "..."}` — already
822+
`model_dump`-ed, never the typed Pydantic instance). `None` values
823+
are stripped, so you can pattern-match present-vs-absent without
824+
explicit `None` checks.
825+
826+
**`ResolveContext` carries the principal.** `ctx.auth_info` is the
827+
verified credential; `ctx.agent` is the registry-resolved
828+
`BuyerAgent` when you've wired a `BuyerAgentRegistry` (preferred for
829+
commercial-relationship gates like
830+
`BILLING_NOT_PERMITTED_FOR_AGENT`). `ctx.tool_name` is
831+
`'sync_accounts'` / `'list_accounts'` for audit logs.
832+
833+
**Wire-envelope wrapping is automatic.** Return
834+
`list[SyncAccountsResultRow]` / `list[Account]` and the framework
835+
projects each row through `to_wire_sync_accounts_row` /
836+
`to_wire_account` (write-only credential strip on
837+
`billing_entity.bank` and `governance_agents[i].authentication`)
838+
and wraps the result as `{"accounts": [...]}` for the wire. Don't
839+
build the envelope yourself — the framework already does it, AND
840+
runs a defense-in-depth credential scrub on the final payload.
841+
842+
For roster-curated stores that reject `sync_accounts` outright (the
843+
adopter manages accounts out-of-band), see
844+
`adcp.decisioning.create_roster_account_store`. For multi-tenant
845+
stores enforcing per-entry tenant isolation, see
846+
`adcp.decisioning.create_tenant_account_store`.
847+
741848
## Account modes and mock-mode upstream routing
742849

743850
The framework recognizes three operationally distinct account modes

examples/sales_proposal_mode_seller/src/app.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ def upsert(
7979
ctx: Any = None,
8080
) -> list[dict[str, Any]]:
8181
"""``sync_accounts`` API. Storyboards call this first to seed
82-
the stateful account chain. Returns one result row per ref."""
82+
the stateful account chain. Returns one result row per ref in
83+
the wire shape per ``schemas/cache/account/sync-accounts-response.json``
84+
— the framework wraps the list as ``{"accounts": [...]}``."""
8385
del ctx
8486
rows: list[dict[str, Any]] = []
8587
for ref in refs:
@@ -95,16 +97,13 @@ def upsert(
9597
account_id = f"acct_{operator}".replace(".", "_")
9698
rows.append(
9799
{
98-
"ref": ref_dict,
99-
"account": {
100-
"account_id": account_id,
101-
"name": f"Account for {domain or operator}",
102-
"status": "active",
103-
"brand": {"domain": domain or "demo.example"},
104-
"operator": operator,
105-
"billing": "operator",
106-
},
107-
"operation": "created",
100+
"account_id": account_id,
101+
"name": f"Account for {domain or operator}",
102+
"brand": {"domain": domain or "demo.example"},
103+
"operator": operator,
104+
"action": "created",
105+
"status": "active",
106+
"billing": "operator",
108107
}
109108
)
110109
return rows

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)