Skip to content

Commit 62b58dc

Browse files
bokelleyclaude
andauthored
feat(server): Account v3 wire fields + AccountStore.upsert/list/syncGovernance receive ResolveContext (#469)
* feat(server): Account v3 wire fields + AccountStore.upsert/list/syncGovernance receive ResolveContext Ports two paired changes from JS @adcp/sdk@6.7 closing out the v6 platform's account-shape story: A. Wire-aligned optional fields on the framework's `Account[TMeta]` dataclass and `SyncAccountsResultRow` — `billing_entity`, `setup`, `governance_agents`, `account_scope`, `payment_terms`, `credit_limit`, `rate_card`, `reporting_bucket` — so adopters can populate the spec's commercial / lifecycle / reporting fields and the framework projects them straight onto the wire on every emit path. New `to_wire_account` / `to_wire_sync_accounts_row` / `to_wire_sync_governance_row` helpers apply two write-only strips per spec: `billing_entity.bank` (bank coordinates flow buyer→seller only) and `governance_agents[i].authentication.credentials` (bearer tokens the seller persists for outbound `check_governance`, never echoed). Defense-in-depth: the strips run even when an adopter returns a loosely-typed dict that smuggles through `cast`/`Any` — Python type hints aren't enforced at runtime, same posture as the JS-side TypeScript-erasure rationale. When `billing_entity` carries only `bank` (no other fields), the projection omits the entire entity rather than emitting an empty object that would fail `legal_name`-required validation downstream. B. New `ResolveContext` carries `auth_info`, `tool_name`, and the resolved `BuyerAgent`. Three new optional Protocol surfaces — `AccountStoreUpsert` / `AccountStoreList` / `AccountStoreSyncGovernance` — declare the ctx-receiving signatures for `sync_accounts` / `list_accounts` / `sync_governance`. Adopters implement these on the same object as `AccountStore` (Protocols are structural; no inheritance needed) and the framework's `_call_with_optional_ctx` shim probes via `inspect.signature` so pre-ctx adopter impls (no `ctx` parameter) keep working unchanged. Adopters now get principal-keyed gates on the spec's billing surfaces — `BILLING_NOT_PERMITTED_FOR_AGENT` per-buyer-agent — without re-deriving identity from the request. Multi-tenant `list_accounts` scoping becomes possible: pre-this-release impls either returned all accounts (over-disclosure) or rejected the operation; post-this-release adopters scope per `ctx.agent`. Opt-in not automatic — the docstring flags the migration step. Public surface additions: `CreditLimit`, `GovernanceAgent`, `PaymentTerms`, `ReportingBucket`, `Setup` re-exported from `adcp.types` so adopter typed access doesn't have to reach into `generated_poc/`. Snapshot regenerated. The v6 sync_accounts / list_accounts / sync_governance dispatch wiring isn't part of this change — Protocol surfaces, projections, and types land first so the next PR (`createTenantStore`-equivalent) has the typed seam to build against. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix-pack: ResolveContext mutability + governance projection drops unknown shapes - Drop frozen=True on ResolveContext: dict extra field made it unhashable anyway, and the immutability promise was false. Adopters who want to attach passthrough context via .extra now can. - _project_governance_agent returns None for unknown shapes (and dicts that project to no visible fields). Both call sites filter None out of the resulting list, so the wire never carries [{}] — silent corruption avoided. - Two new tests cover the unknown-shape and authentication-only-dict cases. Addresses code-review SHOULD-FIX items on PR #469. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ddad4b9 commit 62b58dc

7 files changed

Lines changed: 1370 additions & 20 deletions

File tree

src/adcp/decisioning/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,18 @@ def create_media_buy(
5252
from adcp.decisioning.account_projection import (
5353
project_account_for_response,
5454
project_business_entity_for_response,
55+
to_wire_account,
56+
to_wire_sync_accounts_row,
57+
to_wire_sync_governance_row,
5558
)
5659
from adcp.decisioning.accounts import (
5760
AccountStore,
61+
AccountStoreList,
62+
AccountStoreSyncGovernance,
63+
AccountStoreUpsert,
5864
ExplicitAccounts,
5965
FromAuthAccounts,
66+
ResolveContext,
6067
SingletonAccounts,
6168
)
6269
from adcp.decisioning.compose import (
@@ -163,6 +170,9 @@ def create_media_buy(
163170
AdcpError,
164171
MaybeAsync,
165172
SalesResult,
173+
SyncAccountsResultRow,
174+
SyncGovernanceEntry,
175+
SyncGovernanceResultRow,
166176
TaskHandoff,
167177
WorkflowHandoff,
168178
)
@@ -215,6 +225,9 @@ def __init__(self, *args: object, **kwargs: object) -> None:
215225
"Account",
216226
"AccountNotFoundError",
217227
"AccountStore",
228+
"AccountStoreList",
229+
"AccountStoreSyncGovernance",
230+
"AccountStoreUpsert",
218231
"AdcpError",
219232
"ApiKey",
220233
"ApiKeyCredential",
@@ -267,6 +280,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
267280
"RateLimitedBuyerAgentRegistry",
268281
"RateLimitedError",
269282
"RequestContext",
283+
"ResolveContext",
270284
"ResourceResolver",
271285
"SalesPlatform",
272286
"SalesResult",
@@ -275,6 +289,9 @@ def __init__(self, *args: object, **kwargs: object) -> None:
275289
"SingletonAccounts",
276290
"StateReader",
277291
"StaticBearer",
292+
"SyncAccountsResultRow",
293+
"SyncGovernanceEntry",
294+
"SyncGovernanceResultRow",
278295
"TaskHandoff",
279296
"TaskHandoffContext",
280297
"TaskRegistry",
@@ -304,5 +321,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
304321
"ref_account_id",
305322
"serve",
306323
"signing_only_registry",
324+
"to_wire_account",
325+
"to_wire_sync_accounts_row",
326+
"to_wire_sync_governance_row",
307327
"validate_billing_for_agent",
308328
]

src/adcp/decisioning/account_projection.py

Lines changed: 272 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,46 @@
1-
"""Response-side projection helpers for v3 :class:`Account` payloads.
1+
"""Wire-emit projections for AdCP v3 :class:`Account` payloads.
22
3-
The AdCP v3 spec marks :attr:`adcp.types.BusinessEntity.bank` as
4-
write-only — adopters accept it on inbound ``sync_accounts`` requests
5-
but MUST omit it from any response payload that surfaces an
6-
``Account``. The schema *describes* this rule in a docstring, but
7-
doesn't structurally enforce it; Pydantic round-trips ``bank`` on
8-
``model_dump()`` like any other field.
3+
This module ships two layers of projection:
94
10-
This module ships the structural guard. Adopters call
11-
:func:`project_account_for_response` (or
12-
:func:`project_business_entity_for_response`) on the way out and the
13-
helper returns a fresh model with ``bank`` cleared.
5+
1. **Pydantic-model helpers** — :func:`project_account_for_response`
6+
and :func:`project_business_entity_for_response` operate on the
7+
codegen'd wire :class:`adcp.types.Account` / :class:`BusinessEntity`
8+
models. Adopters that already hold a wire-shaped Pydantic model
9+
(e.g. echoing through a translator) call these to strip the
10+
write-only :attr:`BusinessEntity.bank` before serializing.
11+
12+
2. **Framework dataclass helpers** — :func:`to_wire_account`,
13+
:func:`to_wire_sync_accounts_row`, and
14+
:func:`to_wire_sync_governance_row` project the framework's
15+
internal :class:`adcp.decisioning.Account[TMeta]` /
16+
:class:`SyncAccountsResultRow` / :class:`SyncGovernanceResultRow`
17+
shapes to plain dicts ready for JSON serialization. These run on
18+
every emit path the framework controls; adopters typically don't
19+
call them directly.
20+
21+
The AdCP v3 spec marks two write-only paths that the framework MUST
22+
strip on every response:
23+
24+
* :attr:`BusinessEntity.bank` — bank coordinates flow buyer→seller in
25+
``sync_accounts`` requests but MUST NOT appear in any response
26+
payload.
27+
* :attr:`GovernanceAgent.authentication.credentials` — bearer
28+
credentials the seller persists for outbound ``check_governance``
29+
calls but MUST NOT echo to the buyer or land in the idempotency
30+
replay cache.
31+
32+
The schemas describe both rules in docstrings; neither is structurally
33+
enforced by Pydantic. The helpers here ARE the structural enforcement.
34+
Defense-in-depth: even when an adopter returns a loosely-typed row
35+
that smuggles a ``credentials`` field through ``cast`` / ``Any``, the
36+
projection drops it.
1437
1538
Why a separate function instead of a Pydantic ``field_serializer``?
16-
The framework's typed :class:`Account` model is auto-generated from
17-
the spec schema — patching it in-place would drift on every regen.
18-
Keeping projection in adopter-callable helpers means the wire shape
19-
stays exactly what the spec defines while adopters get a one-line
20-
guard against the leak.
39+
The framework's typed wire models are auto-generated from the spec
40+
schema — patching them in-place would drift on every regen. Keeping
41+
projection in adopter-callable / framework-internal helpers means the
42+
wire shape stays exactly what the spec defines while the strips run
43+
at the emit boundary.
2144
2245
Quickstart::
2346
@@ -34,9 +57,16 @@
3457

3558
from __future__ import annotations
3659

37-
from typing import TYPE_CHECKING
60+
from typing import TYPE_CHECKING, Any
3861

3962
if TYPE_CHECKING:
63+
from adcp.decisioning.types import (
64+
Account as DecisioningAccount,
65+
)
66+
from adcp.decisioning.types import (
67+
SyncAccountsResultRow,
68+
SyncGovernanceResultRow,
69+
)
4070
from adcp.types import Account, BusinessEntity
4171

4272

@@ -77,7 +107,232 @@ def project_business_entity_for_response(entity: BusinessEntity) -> BusinessEnti
77107
return entity.model_copy(update={"bank": None})
78108

79109

110+
# ---------------------------------------------------------------------------
111+
# Framework-dataclass → wire-dict projections
112+
# ---------------------------------------------------------------------------
113+
114+
115+
def _project_billing_entity(entity: Any) -> dict[str, Any] | None:
116+
"""Project a :class:`BusinessEntity` to a wire dict, stripping
117+
``bank`` per the schema's write-only constraint.
118+
119+
Returns ``None`` when the entity carries only ``bank`` (no other
120+
fields). The wire schema requires ``legal_name`` on every emitted
121+
entity; a bank-only input would project to an empty dict that
122+
fails downstream validation, so the caller skips emission entirely
123+
in that case.
124+
125+
Accepts any object with ``model_dump`` (Pydantic) OR a plain dict
126+
so adopters returning either shape on
127+
:class:`SyncAccountsResultRow` work without coercion.
128+
"""
129+
if entity is None:
130+
return None
131+
if hasattr(entity, "model_dump"):
132+
dumped = entity.model_dump(mode="json", exclude_none=True)
133+
elif isinstance(entity, dict):
134+
dumped = {k: v for k, v in entity.items() if v is not None}
135+
else:
136+
return None
137+
dumped.pop("bank", None)
138+
return dumped if dumped else None
139+
140+
141+
def _project_governance_agent(agent: Any) -> dict[str, Any] | None:
142+
"""Project one ``governance_agents[i]`` to a wire dict carrying
143+
only the buyer-visible fields.
144+
145+
The wire schema for response payloads exposes ``url`` and
146+
``categories`` only — :attr:`GovernanceAgent.authentication`
147+
(bearing the write-only credentials) is stripped.
148+
149+
Defense-in-depth: even if an adopter returns a loosely-typed
150+
record with an ``authentication`` key (Python type hints aren't
151+
enforced at runtime), the projection drops it. Same posture as
152+
the JS-side ``projectGovernanceAgent``.
153+
154+
Returns ``None`` for unknown shapes so callers can drop the entry
155+
rather than emit ``{}`` onto the wire (silent corruption).
156+
"""
157+
if hasattr(agent, "model_dump"):
158+
dumped = agent.model_dump(mode="json", exclude_none=True)
159+
elif isinstance(agent, dict):
160+
dumped = {k: v for k, v in agent.items() if v is not None}
161+
else:
162+
return None
163+
out: dict[str, Any] = {}
164+
if "url" in dumped:
165+
out["url"] = dumped["url"]
166+
if "categories" in dumped and dumped["categories"] is not None:
167+
out["categories"] = dumped["categories"]
168+
return out if out else None
169+
170+
171+
def _maybe_dump(value: Any) -> Any:
172+
"""Dump a Pydantic model to JSON-mode dict; pass through dicts /
173+
primitives unchanged. Used by the wire-emit projections to handle
174+
adopters that return either typed Pydantic models or plain
175+
dicts."""
176+
if value is None:
177+
return None
178+
if hasattr(value, "model_dump"):
179+
return value.model_dump(mode="json", exclude_none=True)
180+
return value
181+
182+
183+
def _enum_value(value: Any) -> Any:
184+
"""Return ``value.value`` for Enum-like inputs, else the value
185+
unchanged. Used to project codegen'd enum types AND adopter-supplied
186+
string literals to the same wire string shape."""
187+
if hasattr(value, "value"):
188+
return value.value
189+
return value
190+
191+
192+
def to_wire_account(account: DecisioningAccount[Any]) -> dict[str, Any]:
193+
"""Project a framework :class:`Account[TMeta]` to the wire
194+
``Account`` shape.
195+
196+
Strips ``metadata`` and ``auth_info`` (framework-internal — never
197+
on the wire); renames ``id`` → ``account_id``; passes through
198+
wire-shaped optional fields. Strips ``billing_entity.bank``
199+
per the schema's write-only constraint.
200+
201+
For ``governance_agents``, strips ``authentication`` from every
202+
element (defense-in-depth — TypeScript erasure means Python type
203+
hints can't enforce credentials-out at runtime, so the projection
204+
is explicit at every emit boundary).
205+
206+
Used by the framework when emitting any response that surfaces an
207+
:class:`Account`. Adopters never call this directly — they return
208+
:class:`Account[TMeta]` from :meth:`AccountStore.resolve` /
209+
:meth:`AccountStore.list` and the framework projects.
210+
"""
211+
wire: dict[str, Any] = {
212+
"account_id": account.id,
213+
"name": account.name,
214+
"status": account.status,
215+
}
216+
projected_entity = _project_billing_entity(account.billing_entity)
217+
if projected_entity is not None:
218+
wire["billing_entity"] = projected_entity
219+
if account.setup is not None:
220+
wire["setup"] = _maybe_dump(account.setup)
221+
if account.governance_agents is not None:
222+
projected_agents = [
223+
p
224+
for p in (_project_governance_agent(a) for a in account.governance_agents)
225+
if p is not None
226+
]
227+
wire["governance_agents"] = projected_agents
228+
if account.account_scope is not None:
229+
scope = account.account_scope
230+
wire["account_scope"] = _enum_value(scope)
231+
if account.payment_terms is not None:
232+
terms = account.payment_terms
233+
wire["payment_terms"] = _enum_value(terms)
234+
if account.credit_limit is not None:
235+
wire["credit_limit"] = _maybe_dump(account.credit_limit)
236+
if account.rate_card is not None:
237+
wire["rate_card"] = account.rate_card
238+
if account.reporting_bucket is not None:
239+
wire["reporting_bucket"] = _maybe_dump(account.reporting_bucket)
240+
return wire
241+
242+
243+
def to_wire_sync_accounts_row(row: SyncAccountsResultRow) -> dict[str, Any]:
244+
"""Project a :class:`SyncAccountsResultRow` to the wire shape
245+
returned by ``sync_accounts``.
246+
247+
Applies the same ``billing_entity.bank`` strip as
248+
:func:`to_wire_account` — the wire schema marks bank coordinates
249+
write-only on EVERY response, not just ``list_accounts``.
250+
Adopters returning a row that spreads a DB record carrying
251+
``bank`` (e.g., ``{**db.findByBrand(r.brand), 'action':
252+
'updated'}``) have it stripped before emit.
253+
254+
Used by the framework when emitting ``sync_accounts`` responses.
255+
Adopters never call this directly — they return
256+
``list[SyncAccountsResultRow]`` from
257+
:meth:`AccountStore.upsert` and the framework projects.
258+
"""
259+
action = row.action
260+
status = row.status
261+
wire: dict[str, Any] = {
262+
"brand": _maybe_dump(row.brand),
263+
"operator": row.operator,
264+
"action": _enum_value(action),
265+
"status": _enum_value(status),
266+
}
267+
if row.account_id is not None:
268+
wire["account_id"] = row.account_id
269+
if row.name is not None:
270+
wire["name"] = row.name
271+
if row.billing is not None:
272+
wire["billing"] = row.billing
273+
projected_entity = _project_billing_entity(row.billing_entity)
274+
if projected_entity is not None:
275+
wire["billing_entity"] = projected_entity
276+
if row.account_scope is not None:
277+
scope = row.account_scope
278+
wire["account_scope"] = _enum_value(scope)
279+
if row.setup is not None:
280+
wire["setup"] = _maybe_dump(row.setup)
281+
if row.rate_card is not None:
282+
wire["rate_card"] = row.rate_card
283+
if row.payment_terms is not None:
284+
terms = row.payment_terms
285+
wire["payment_terms"] = _enum_value(terms)
286+
if row.credit_limit is not None:
287+
wire["credit_limit"] = _maybe_dump(row.credit_limit)
288+
if row.errors is not None:
289+
wire["errors"] = list(row.errors)
290+
if row.warnings is not None:
291+
wire["warnings"] = list(row.warnings)
292+
if row.sandbox is not None:
293+
wire["sandbox"] = row.sandbox
294+
return wire
295+
296+
297+
def to_wire_sync_governance_row(row: SyncGovernanceResultRow) -> dict[str, Any]:
298+
"""Project a :class:`SyncGovernanceResultRow` to the wire shape
299+
returned by ``sync_governance``.
300+
301+
Critically: each ``governance_agents[i]`` is reduced to
302+
``{url, categories?}`` only — the spec marks
303+
``authentication.credentials`` write-only (the buyer sends the
304+
bearer; the seller persists it for outbound ``check_governance``
305+
calls but MUST NOT echo it back). The natural ``{**entry_agent}``
306+
echo idiom would compile silently against a loose return type
307+
and ship credentials over the wire AND into the idempotency
308+
replay cache, arming the buyer (and any subsequent caller hitting
309+
the same key) to impersonate the seller against the governance
310+
agent.
311+
312+
Defense-in-depth: this dispatcher-level strip runs even when an
313+
adopter returns a loosely-typed row that spreads the input
314+
governance-agent record verbatim. Same posture as the JS-side
315+
``toWireSyncGovernanceRow``.
316+
"""
317+
wire: dict[str, Any] = {
318+
"account": _maybe_dump(row.account),
319+
"status": _enum_value(row.status),
320+
}
321+
if row.governance_agents is not None:
322+
wire["governance_agents"] = [
323+
p
324+
for p in (_project_governance_agent(a) for a in row.governance_agents)
325+
if p is not None
326+
]
327+
if row.errors is not None:
328+
wire["errors"] = list(row.errors)
329+
return wire
330+
331+
80332
__all__ = [
81333
"project_account_for_response",
82334
"project_business_entity_for_response",
335+
"to_wire_account",
336+
"to_wire_sync_accounts_row",
337+
"to_wire_sync_governance_row",
83338
]

0 commit comments

Comments
 (0)