Module adcp.decisioning.account_projection
Wire-emit projections for AdCP v3 :class:Account payloads.
This module ships two layers of projection:
-
Pydantic-model helpers — :func:
project_account_for_response()and :func:project_business_entity_for_response()operate on the codegen'd wire :class:Account/ :class:BusinessEntitymodels. Adopters that already hold a wire-shaped Pydantic model (e.g. echoing through a translator) call these to strip the write-only :attr:BusinessEntity.bankbefore serializing. -
Framework dataclass helpers — :func:
to_wire_account(), :func:to_wire_sync_accounts_row(), and :func:to_wire_sync_governance_row()project the framework's internal :class:Account[TMeta]/ :class:SyncAccountsResultRow/ :class:SyncGovernanceResultRowshapes to plain dicts ready for JSON serialization. These run on every emit path the framework controls; adopters typically don't call them directly.
The AdCP v3 spec marks two write-only paths that the framework MUST strip on every response:
- :attr:
BusinessEntity.bank— bank coordinates flow buyer→seller insync_accountsrequests but MUST NOT appear in any response payload. - :attr:
GovernanceAgent.authentication.credentials— bearer credentials the seller persists for outboundcheck_governancecalls but MUST NOT echo to the buyer or land in the idempotency replay cache.
The schemas describe both rules in docstrings; neither is structurally
enforced by Pydantic. The helpers here ARE the structural enforcement.
Defense-in-depth: even when an adopter returns a loosely-typed row
that smuggles a credentials field through cast / Any, the
projection drops it.
Why a separate function instead of a Pydantic field_serializer?
The framework's typed wire models are auto-generated from the spec
schema — patching them in-place would drift on every regen. Keeping
projection in adopter-callable / framework-internal helpers means the
wire shape stays exactly what the spec defines while the strips run
at the emit boundary.
Quickstart::
from adcp.types import Account
from adcp.decisioning import project_account_for_response
# Adopter persists <code>account</code> with billing_entity.bank populated
# for invoicing. On the response path:
response_payload = project_account_for_response(account).model_dump(
mode="json", exclude_none=True,
)
# `response_payload['billing_entity']` no longer carries 'bank'.
Global variables
var CREDENTIAL_BEARING_METHODS : frozenset[str]-
This set is intentionally broad: any tool whose response surfaces an
Accountenvelope (billing_entity,governance_agents) or a joined record carrying those keys belongs here.
Functions
def project_account_for_response(account: Account) ‑> Account-
Expand source code
def project_account_for_response(account: Account) -> Account: """Return a copy of ``account`` safe to serialize on a response. Strips :attr:`Account.billing_entity.bank` — the AdCP v3 spec marks bank details as write-only. Adopters that persist the full :class:`BusinessEntity` (with bank populated for invoicing) MUST project through this helper before serializing on any response. Returns the input unchanged when ``billing_entity`` is ``None`` or ``billing_entity.bank`` is already absent — defensive copy via ``model_copy()`` so callers can mutate the returned object freely without touching the caller's input. The original ``account`` object is not modified. """ if account.billing_entity is None or account.billing_entity.bank is None: return account.model_copy() safe_billing_entity = account.billing_entity.model_copy(update={"bank": None}) return account.model_copy(update={"billing_entity": safe_billing_entity})Return a copy of
accountsafe to serialize on a response.Strips :attr:
Account.billing_entity.bank— the AdCP v3 spec marks bank details as write-only. Adopters that persist the full :class:BusinessEntity(with bank populated for invoicing) MUST project through this helper before serializing on any response.Returns the input unchanged when
billing_entityisNoneorbilling_entity.bankis already absent — defensive copy viamodel_copy()so callers can mutate the returned object freely without touching the caller's input.The original
accountobject is not modified. def project_business_entity_for_response(entity: BusinessEntity) ‑> BusinessEntity-
Expand source code
def project_business_entity_for_response(entity: BusinessEntity) -> BusinessEntity: """Return a copy of ``entity`` with ``bank`` cleared. Same posture as :func:`project_account_for_response` but operating on a :class:`BusinessEntity` directly — useful for adopters serializing standalone billing-entity payloads (admin APIs, brand-rights flows) that don't go through the :class:`Account` envelope. The original ``entity`` is not modified. """ if entity.bank is None: return entity.model_copy() return entity.model_copy(update={"bank": None})Return a copy of
entitywithbankcleared.Same posture as :func:
project_account_for_response()but operating on a :class:BusinessEntitydirectly — useful for adopters serializing standalone billing-entity payloads (admin APIs, brand-rights flows) that don't go through the :class:Accountenvelope.The original
entityis not modified. def strip_credentials_from_wire_result(method_name: str, result: Any) ‑> Any-
Expand source code
def strip_credentials_from_wire_result(method_name: str, result: Any) -> Any: """Strip write-only credential fields from a wire-shape result. Defense-in-depth boundary called by the dispatcher on every response that may surface an :class:`Account` envelope. Removes ``governance_agents[i].authentication`` and ``billing_entity.bank`` recursively — the same fields the typed projections (:func:`to_wire_account`, :func:`to_wire_sync_governance_row`) strip when the adopter returns the framework's typed dataclasses. Adopters returning a loose dict (or a Pydantic model with ``extra='allow'``) bypass the typed projections; this scrubber catches them. Adopters returning the typed dataclasses get double-stripped — the second pass is a no-op since the typed projections already removed the fields. Method gate: the scrubber is O(n) in result size; we only run it on methods in :data:`CREDENTIAL_BEARING_METHODS`. Non-account methods (``get_products``, ``get_signals``, ``activate_signal``) skip the walk entirely and pass through unchanged. The input is not mutated — returns a new value. """ if method_name not in CREDENTIAL_BEARING_METHODS: return result if isinstance(result, dict): return _scrub_dict(result) if isinstance(result, list): return [_scrub_value(v) for v in result] # Typed Pydantic response models pass through unchanged — the # response-side codegen'd shapes don't define ``authentication`` # on ``GovernanceAgent`` or ``bank`` on the response-side # ``BusinessEntity``, so the schema enforces the strip # structurally. Dumping-and-scrubbing a model would force # downstream callers to lose typed-model identity for no # security gain. The leak vector is loose dicts and Pydantic # ``extra='allow'`` models that smuggle credentials past the # codegen schema; both arrive as ``dict`` after the adopter's # method returns or via the registry's ``model_dump`` path. return resultStrip write-only credential fields from a wire-shape result.
Defense-in-depth boundary called by the dispatcher on every response that may surface an :class:
Accountenvelope. Removesgovernance_agents[i].authenticationandbilling_entity.bankrecursively — the same fields the typed projections (:func:to_wire_account(), :func:to_wire_sync_governance_row()) strip when the adopter returns the framework's typed dataclasses.Adopters returning a loose dict (or a Pydantic model with
extra='allow') bypass the typed projections; this scrubber catches them. Adopters returning the typed dataclasses get double-stripped — the second pass is a no-op since the typed projections already removed the fields.Method gate: the scrubber is O(n) in result size; we only run it on methods in :data:
CREDENTIAL_BEARING_METHODS. Non-account methods (get_products,get_signals,activate_signal) skip the walk entirely and pass through unchanged.The input is not mutated — returns a new value.
def to_wire_account(account: DecisioningAccount[Any]) ‑> dict[str, Any]-
Expand source code
def to_wire_account(account: DecisioningAccount[Any]) -> dict[str, Any]: """Project a framework :class:`Account[TMeta]` to the wire ``Account`` shape. Strips ``metadata`` and ``auth_info`` (framework-internal — never on the wire); renames ``id`` → ``account_id``; passes through wire-shaped optional fields. Strips ``billing_entity.bank`` per the schema's write-only constraint. For ``governance_agents``, strips ``authentication`` from every element (defense-in-depth — TypeScript erasure means Python type hints can't enforce credentials-out at runtime, so the projection is explicit at every emit boundary). Used by the framework when emitting any response that surfaces an :class:`Account`. Adopters never call this directly — they return :class:`Account[TMeta]` from :meth:`AccountStore.resolve` / :meth:`AccountStore.list` and the framework projects. """ wire: dict[str, Any] = { "account_id": account.id, "name": account.name, "status": account.status, } projected_entity = _project_billing_entity(account.billing_entity) if projected_entity is not None: wire["billing_entity"] = projected_entity if account.setup is not None: wire["setup"] = _maybe_dump(account.setup) if account.governance_agents is not None: projected_agents = [ p for p in (_project_governance_agent(a) for a in account.governance_agents) if p is not None ] wire["governance_agents"] = projected_agents if account.account_scope is not None: scope = account.account_scope wire["account_scope"] = _enum_value(scope) if account.payment_terms is not None: terms = account.payment_terms wire["payment_terms"] = _enum_value(terms) if account.credit_limit is not None: wire["credit_limit"] = _maybe_dump(account.credit_limit) if account.rate_card is not None: wire["rate_card"] = account.rate_card if account.reporting_bucket is not None: wire["reporting_bucket"] = _maybe_dump(account.reporting_bucket) if account.authorization is not None: authorization = _project_account_authorization(account.authorization) if authorization is not None: wire["authorization"] = authorization return wireProject a framework :class:
Account[TMeta]to the wireAccountshape.Strips
metadataandauth_info(framework-internal — never on the wire); renamesid→account_id; passes through wire-shaped optional fields. Stripsbilling_entity.bankper the schema's write-only constraint.For
governance_agents, stripsauthenticationfrom every element (defense-in-depth — TypeScript erasure means Python type hints can't enforce credentials-out at runtime, so the projection is explicit at every emit boundary).Used by the framework when emitting any response that surfaces an :class:
Account. Adopters never call this directly — they return :class:Account[TMeta]from :meth:AccountStore.resolve/ :meth:AccountStore.listand the framework projects. def to_wire_sync_accounts_row(row: SyncAccountsResultRow) ‑> dict[str, Any]-
Expand source code
def to_wire_sync_accounts_row(row: SyncAccountsResultRow) -> dict[str, Any]: """Project a :class:`SyncAccountsResultRow` to the wire shape returned by ``sync_accounts``. Applies the same ``billing_entity.bank`` strip as :func:`to_wire_account` — the wire schema marks bank coordinates write-only on EVERY response, not just ``list_accounts``. Adopters returning a row that spreads a DB record carrying ``bank`` (e.g., ``{**db.findByBrand(r.brand), 'action': 'updated'}``) have it stripped before emit. Used by the framework when emitting ``sync_accounts`` responses. Adopters never call this directly — they return ``list[SyncAccountsResultRow]`` from :meth:`AccountStore.upsert` and the framework projects. """ action = row.action status = row.status wire: dict[str, Any] = { "brand": _maybe_dump(row.brand), "operator": row.operator, "action": _enum_value(action), "status": _enum_value(status), } if row.account_id is not None: wire["account_id"] = row.account_id if row.name is not None: wire["name"] = row.name if row.billing is not None: wire["billing"] = row.billing projected_entity = _project_billing_entity(row.billing_entity) if projected_entity is not None: wire["billing_entity"] = projected_entity if row.account_scope is not None: scope = row.account_scope wire["account_scope"] = _enum_value(scope) if row.setup is not None: wire["setup"] = _maybe_dump(row.setup) if row.rate_card is not None: wire["rate_card"] = row.rate_card if row.payment_terms is not None: terms = row.payment_terms wire["payment_terms"] = _enum_value(terms) if row.credit_limit is not None: wire["credit_limit"] = _maybe_dump(row.credit_limit) if row.errors is not None: wire["errors"] = list(row.errors) if row.warnings is not None: wire["warnings"] = list(row.warnings) if row.sandbox is not None: wire["sandbox"] = row.sandbox return wireProject a :class:
SyncAccountsResultRowto the wire shape returned bysync_accounts.Applies the same
billing_entity.bankstrip as :func:to_wire_account()— the wire schema marks bank coordinates write-only on EVERY response, not justlist_accounts. Adopters returning a row that spreads a DB record carryingbank(e.g.,{**db.findByBrand(r.brand), 'action': 'updated'}) have it stripped before emit.Used by the framework when emitting
sync_accountsresponses. Adopters never call this directly — they returnlist[SyncAccountsResultRow]from :meth:AccountStore.upsertand the framework projects. def to_wire_sync_governance_row(row: SyncGovernanceResultRow) ‑> dict[str, Any]-
Expand source code
def to_wire_sync_governance_row(row: SyncGovernanceResultRow) -> dict[str, Any]: """Project a :class:`SyncGovernanceResultRow` to the wire shape returned by ``sync_governance``. Critically: each ``governance_agents[i]`` is reduced to ``{url, categories?}`` only — the spec marks ``authentication.credentials`` write-only (the buyer sends the bearer; the seller persists it for outbound ``check_governance`` calls but MUST NOT echo it back). The natural ``{**entry_agent}`` echo idiom would compile silently against a loose return type and ship credentials over the wire AND into the idempotency replay cache, arming the buyer (and any subsequent caller hitting the same key) to impersonate the seller against the governance agent. Defense-in-depth: this dispatcher-level strip runs even when an adopter returns a loosely-typed row that spreads the input governance-agent record verbatim. Same posture as the JS-side ``toWireSyncGovernanceRow``. """ wire: dict[str, Any] = { "account": _maybe_dump(row.account), "status": _enum_value(row.status), } if row.governance_agents is not None: wire["governance_agents"] = [ p for p in (_project_governance_agent(a) for a in row.governance_agents) if p is not None ] if row.errors is not None: wire["errors"] = list(row.errors) return wireProject a :class:
SyncGovernanceResultRowto the wire shape returned bysync_governance.Critically: each
governance_agents[i]is reduced to{url, categories?}only — the spec marksauthentication.credentialswrite-only (the buyer sends the bearer; the seller persists it for outboundcheck_governancecalls but MUST NOT echo it back). The natural{**entry_agent}echo idiom would compile silently against a loose return type and ship credentials over the wire AND into the idempotency replay cache, arming the buyer (and any subsequent caller hitting the same key) to impersonate the seller against the governance agent.Defense-in-depth: this dispatcher-level strip runs even when an adopter returns a loosely-typed row that spreads the input governance-agent record verbatim. Same posture as the JS-side
toWireSyncGovernanceRow.