Repository navigation
fix(accounts): never serve destination_billing_entity on account reads - #1823
Merged
Merged
Conversation
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>
There was a problem hiding this comment.
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 insrc/adcp/decisioning/account_projection.py:_scrub_dictdrops the key at every depth andproject_account_for_responseclears it. - Verified the recursive
_scrub_value/_scrub_dictwalk reaches the field at all call boundaries (dispatch, handler, task registries, webhook emit) via its nesting underaccounts/accountkeys; 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.
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.
Summary
Account.destination_billing_entityis a request-only staging field for an operator-domain handoff. The spec says it must not appear in account read models:core/account.jsondeclares it"not": {}with "MUST NOT appear in account read models", andsync-accounts-response.jsonsays the same forsync_accountsresults. The Python scrubber only strippedbilling_entity.bank. A store that echoed its stored account (as a loose dict or anextra='allow'model) therefore served the field on reads.strip_credentials_from_wire_result(_scrub_dict) now dropsdestination_billing_entityentirely, non-bank fields included, at every depth of a credential-bearing response. That coverslist_accountsrows,account_requests[]items,request_account,sync_accountsresults, 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_responseclears the field as well.to_wire_account,to_wire_sync_accounts_row) build their output from an allowlist, so they never emitted the field. They are unchanged.sync_accountsrequest.Parity with TS
This matches
stripAccountWriteOnlyFieldsinsrc/lib/server/responses.ts, which dropsdestination_billing_entityfromlist_accountsandsync_accountsaccounts and frommedia_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 carriesdestination_billing_entity, so the existingaccount_requests[]andrequest_accounttest covers it. Two new handler-level tests cover alist_accountsrow from a loose-dict store and async_accountsresult from an echoingupsert.tests/test_account_projection.py:project_account_for_responseclears the field and does not mutate its input.All four tests fail without the fix and pass with it.
ruff check,ruff format --checkandmypy(run as CI runs them) are clean. The account, credential and sync suites pass locally: 2981 passed, 270 skipped.🤖 Generated with Claude Code