feat(types): Account v3 projection helpers — bank-details write-only guard (#356)#371
Merged
feat(types): Account v3 projection helpers — bank-details write-only guard (#356)#371
Conversation
…guard Adds adcp.types.projections — projection types that strip write-only fields on the response edge. Closes #356. Per AdCP 3.0.x spec, BusinessEntity.bank carries writeOnly: true: buyers send bank coordinates as part of account setup, sellers store them, but sellers MUST NOT echo them back. Pydantic's default serialization round-trips everything, so an adopter who reuses an internal Account model on the response path can leak IBAN/routing numbers without realizing it. Per security-reviewer's pre-PR findings on issue #356, Option 2 (projection types) is structurally safer than Option 1 (model_validator) because it closes the model_copy / direct-serialization / idempotency- cache bypass paths a strip-only validator leaves open. What ships: - BusinessEntityResponse: subclass that rejects bank=... at construction (ValidationError) and excludes the field from serialization regardless. - AccountResponse: subclass binding billing_entity to BusinessEntityResponse so the guard travels with Account-level serialization. - to_account_response(account): adopter helper that projects an internal Account to the response shape, dropping bank along the way. Preserves reporting_bucket (NOT write-only — buyer-readable for offline delivery coordinates per ad-tech-protocol-expert clarification on the issue). Tests: 7 behavioral assertions covering construction guard, serialization guard, type binding, AccountResponse rejection, internal-Account strip helper, and reporting_bucket preservation. Out of scope (track separately): GovernanceAgent.authentication is also write-only but lives in a generated nested type with a different adopter contract; sync_accounts_response.Account is constructed via SyncAccountsResponse1/2 discriminator branches that don't share billing_entity wire shape with core.Account. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #356.
Summary
New module `adcp.types.projections` with response-shape projection types that close the bank-details-on-read leak path on `Account` responses.
Per AdCP 3.0.x `core/business-entity.json`, `BusinessEntity.bank` carries `writeOnly: true` — IBAN, BIC, routing/account numbers flow into the seller during account setup but MUST NOT be echoed in responses. Pydantic round-trips everything by default, so an adopter who reuses an internal `Account` model on the response path leaks bank details unknowingly.
Design choice — Option 2 (projection types)
Per security-reviewer on the issue, Option 1 (model_validator strip) has multiple silent bypass paths: `model_copy()`, direct `BusinessEntity` serialization, idempotency cache replay. Option 2 (projection types) is structurally correct — the field is type-narrowed to `None`, so an adopter holding the projection type literally cannot construct one with bank set.
Public surface
```python
from adcp.types import (
AccountResponse,
BusinessEntityResponse,
to_account_response,
)
Convert an internal Account (with bank populated) to a safe response:
response = to_account_response(internal_account)
response.billing_entity.bank is None; serialization omits the field.
Or construct directly:
resp = AccountResponse(
account_id="acct-1", name="Acme", status="active",
billing_entity={"legal_name": "Acme Corp"}, # bank=... raises ValidationError
)
```
Behavior pinned by tests
Out of scope (track separately)
Local gates
Test plan
🤖 Generated with Claude Code