Skip to content

fix(accounts): never serve destination_billing_entity on account reads - #1823

Merged
bokelley merged 1 commit into
nextfrom
claude/strip-destination-billing-entity
Nov 3, 2026
Merged

bokelley merged 1 commit into
nextfrom
claude/strip-destination-billing-entity

Conversation

@bokelley

@bokelley bokelley commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Account.destination_billing_entity is a request-only staging field for an operator-domain handoff. The spec says it must not appear in account read models: core/account.json declares it "not": {} with "MUST NOT appear in account read models", and sync-accounts-response.json says the same for sync_accounts results. The Python scrubber only stripped billing_entity.bank. A store that echoed its stored account (as a loose dict or an extra='allow' model) therefore served the field on reads.

  • strip_credentials_from_wire_result (_scrub_dict) now drops destination_billing_entity entirely, non-bank fields included, at every depth of a credential-bearing response. That covers list_accounts rows, account_requests[] items, request_account, sync_accounts results, and accounts embedded in media-buy responses. It also covers the paths that already go through this scrubber: the public response builders, webhook emission, and both task registries.
  • project_account_for_response clears the field as well.
  • The typed projections (to_wire_account, to_wire_sync_accounts_row) build their output from an allowlist, so they never emitted the field. They are unchanged.
  • Write paths are untouched. The scrubber only runs on responses, so a buyer can still send the field on a sync_accounts request.

Parity with TS

This matches stripAccountWriteOnlyFields in src/lib/server/responses.ts, which drops destination_billing_entity from list_accounts and sync_accounts accounts and from media_buy.account. The Python scrubber walks the whole response by key, so it drops the field anywhere in a credential-bearing response, not only on known account positions. No response schema permits the field anywhere, so the wider net costs nothing.

Tests

  • tests/test_account_requests_seller.py: the shared credentialed-account fixture now carries destination_billing_entity, so the existing account_requests[] and request_account test covers it. Two new handler-level tests cover a list_accounts row from a loose-dict store and a sync_accounts result from an echoing upsert.
  • tests/test_account_projection.py: project_account_for_response clears the field and does not mutate its input.

All four tests fail without the fix and pass with it. ruff check, ruff format --check and mypy (run as CI runs them) are clean. The account, credential and sync suites pass locally: 2981 passed, 270 skipped.

🤖 Generated with Claude Code

Account.destination_billing_entity is a request-only staging field for an
operator-domain handoff. The spec says it MUST NOT appear in account read
models (core/account.json, sync-accounts-response.json), but the
defense-in-depth scrubber only stripped billing_entity.bank, so a store that
echoed its stored account (loose dict or extra='allow' model) served it on
list_accounts rows, account_requests[], request_account and sync_accounts.

strip_credentials_from_wire_result now drops the key wholesale, non-bank
fields included, at every depth of a credential-bearing response, which
also covers accounts embedded in media-buy responses, the response
builders, webhook emission and the task registry. project_account_for_response
clears it too. Requests are untouched: the scrubber only runs on responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean fix with no blocking or medium findings.

Checked:

  • Adds destination_billing_entity (request-only operator-handoff staging field, spec-marked MUST NOT appear in account read models) to the defense-in-depth strips in src/adcp/decisioning/account_projection.py: _scrub_dict drops the key at every depth and project_account_for_response clears it.
  • Verified the recursive _scrub_value/_scrub_dict walk reaches the field at all call boundaries (dispatch, handler, task registries, webhook emit) via its nesting under accounts/account keys; no top-level response model exposes it directly.
  • Typed allowlist projections never emitted the field and are correctly left untouched.
  • Tests fail-without/pass-with across list_accounts, sync_accounts, account_requests[], and projection paths.
  • fix: is the correct semver signal — stopping a leak of a request-only field does not break conformant deserialization.

Decision-table trace: no critical/high findings (row 1 n/a); gated_paths false (row 2 n/a); high_risk reason is (modified) but with zero medium findings rows 3–5 do not fire; no prior decision (row 6 n/a); no no-auto-approve team match (row 7 n/a); zero medium findings (row 8 n/a). Falls through to row 9 → approve. The high_risk flag is a (modified) file with no medium-or-higher concern, which is presumed safe.

@bokelley
bokelley merged commit 50dc31e into next Nov 3, 2026
41 checks passed
@bokelley
bokelley deleted the claude/strip-destination-billing-entity branch November 3, 2026 00:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant