|
1 | | -"""Response-side projection helpers for v3 :class:`Account` payloads. |
| 1 | +"""Wire-emit projections for AdCP v3 :class:`Account` payloads. |
2 | 2 |
|
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: |
9 | 4 |
|
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. |
14 | 37 |
|
15 | 38 | 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. |
21 | 44 |
|
22 | 45 | Quickstart:: |
23 | 46 |
|
|
34 | 57 |
|
35 | 58 | from __future__ import annotations |
36 | 59 |
|
37 | | -from typing import TYPE_CHECKING |
| 60 | +from typing import TYPE_CHECKING, Any |
38 | 61 |
|
39 | 62 | 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 | + ) |
40 | 70 | from adcp.types import Account, BusinessEntity |
41 | 71 |
|
42 | 72 |
|
@@ -77,7 +107,232 @@ def project_business_entity_for_response(entity: BusinessEntity) -> BusinessEnti |
77 | 107 | return entity.model_copy(update={"bank": None}) |
78 | 108 |
|
79 | 109 |
|
| 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 | + |
80 | 332 | __all__ = [ |
81 | 333 | "project_account_for_response", |
82 | 334 | "project_business_entity_for_response", |
| 335 | + "to_wire_account", |
| 336 | + "to_wire_sync_accounts_row", |
| 337 | + "to_wire_sync_governance_row", |
83 | 338 | ] |
0 commit comments