Repository navigation
feat(accounts): add accounts.change and use for account requests - #1777
Conversation
client.accounts.change(account_id, changes, revision=, allow_separate_account=) changes an account through request_account. A money change, to billing, billing_entity or payment_terms, needs the revision it was approved against and is refused before dispatch without it; the SDK never reads it, which would defeat lost-update protection. Other changes read the revision from list_accounts and retry once on CONFLICT when the changed fields did not move concurrently. Outcomes are AccountUpdated, with the new revision and the 3.3.0-beta.3 effective_at for money terms; AccountSeparate, with not_carried_over and the account_ids-scoped subscriptions that will not see the new account; AccountMatched; AccountPending; and AccountRejected, whose requires_new_account names ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT. allow_separate_account defaults to false, and change never re-points the registry: client.accounts.use(key, account_id) is the explicit step. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The producer row-upload test stamps its worker lease with the settling harness clock, fixed at 2026-11-01T02:10Z, while the row store checks the lease against the database's clock_timestamp. Once the wall clock passed that instant plus the lease, every run failed with STATE_UNAVAILABLE: no live worker lease covers this row upload, on next and on every pull request. The test now runs its turn at the wall clock, so the lease lies in the real future. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> (cherry picked from commit ae15bab)
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — accounts.change/use additive feature, no blocking findings.
Checked:
- New public exports are additive under
feat:— semver signal correct, snapshot +4. - Money changes refuse pre-dispatch without revision (lost-update protection, never SDK-read), tested.
- Non-money changes read revision and retry exactly once on CONFLICT only when the changed field didn't move concurrently — bounded, idempotency key rotates per revision, tested.
account.requestscapability gate refuses unsupported sellers (Refused pathway, tested).subscriptions_not_coveringis best-effort/informational and swallows safely.- high_risk flag fired only on
src/adcp/__init__.py (modified)— additive re-exports, no medium-or-higher concern on that file, so presumed safe.
Medium findings
- src/adcp/accounts.py:606 — use() hardcodes status="active", can bypass prepare() spend/payment gate
One medium finding, no critical/high, no escalation trigger (gated_paths false, no deletion, finding category is not data-loss/schema/infra, no medium on the modified high-risk file, no team gate). One medium falls through to row 9 → approve.
Medium findings
- src/adcp/accounts.py:606 — use() hardcodes status="active", can bypass prepare() spend/payment gate
use stamped every re-pointed account active, which let the spend gate pass a matched account that was payment_required or pending. It now records the status list_accounts shows, or the one passed in, and leaves an account the caller cannot list without a status. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — additive feature (client.accounts.change()/use() over request_account) with correct semver signal.
What I checked:
- Semver signal is correct:
feat(accounts), four additive public exports (AccountUpdated/AccountSeparate/AccountMatched/AccountChangeOutcome),public_api_snapshot.jsonregenerated. No breaking change, no removed/renamed exports, no required→optional flips. - No generated-code hand-edits (
src/adcp/types/domains/**,_generated.pyuntouched); forward-compat unions and credential screening (_CREDENTIAL_SHAPED_KEY_SUFFIXES) untouched. - Largest file (accounts.py, +264): money-change revision refusal (lost-update protection), automatic read-then-send with single CONFLICT retry gated on unchanged fields + fresh idempotency key, the ACCOUNT_REQUESTS_UNSUPPORTED "refused" pathway (DR-0032/0033, tested), and
_change_outcomeresolution routing are all sound and tested. - Wire-change pathway requirement satisfied: the unsupported-feature case uses the typed "refused" pathway with a test.
- Reporting test change is a legitimate wall-clock lease fix, not a disabled test — CI gates remain armed.
Medium findings:
- src/adcp/accounts.py:661 — change() pending outcomes not tracked by registry; refresh()/pending() can't surface a pending change's resolution. Likely intentional but undocumented.
Decision path: No critical/high findings (row 1 N/A). gated_paths false (row 2 N/A). high_risk is true only via src/adcp/__init__.py (modified), but row 5 requires a medium finding on a modified high-risk file — the sole medium is on accounts.py, not the flagged init.py, so row 5 does not fire. No data-loss/schema/infra medium (row 4 N/A). No deletions, no author team gate, only one medium finding (below the row-8 threshold of three). Falls through to row 9 → approve.
Medium findings
- src/adcp/accounts.py:661 — change() pending outcomes not tracked by registry; refresh() can't surface resolution
change returns a pending change without keeping it in the registry, which maps natural keys rather than account ids. change_status(account_id, request_id) reads the request back through list_accounts and returns its current outcome. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — follow-up pass resolves both prior-review concerns with no new blocking findings.
This is a subsequent review. The prior decision was approve with one medium finding (accounts.py:661 — change() pending outcomes not tracked by registry). The developer has since pushed commits that:
- Add
change_status()(accounts.py:382), providing a documented readback path so refresh() can surface change resolution — resolving the prior untracked-pending-change medium. - Make
use()read authoritative account status fromlist_accounts(accounts.py:421-423) instead of hard-coding "active", closing the spend-gate status gap (now fail-closed, with a test assertingpayment_requiredsurvivesuse()).
Change-outcome parsing, revision/CONFLICT retry, and the new public exports (AccountChangeOutcome and friends) are additive/non-breaking and sound. The reviewer surfaced no new Critical/High/Medium findings.
Gate checks:
gated_paths: false→ row 2 does not apply (andreview_decisionis APPROVED regardless).high_risk: true, but the only matching file (src/adcp/__init__.py) is(modified)with no medium-or-higher finding → row 5 does not fire; row 3 (deleted) does not apply.- No no-auto-approve team match → row 7 does not apply.
- Prior decision was
approve, notescalate→ row 6 does not apply. - Zero findings → rows 1, 4, 8 do not apply.
No rows 1–8 fire → row 9: approve.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean delta over the prior approve, no blocking or medium findings.
What I checked:
- Adds
accounts.change_status()and makesuse()preserve the account's real status instead of stamping "active". New names are additive underfeat(accounts):— no public export removed, renamed, or re-typed, so no semver break signal is required. Snapshot regenerated (+4) confirms the additive surface. - The prior medium finding (change() pending outcomes untrackable by registry) is now addressed via
change_statusplus the documented request_id contract — a deliberate consequence of the natural-key registry. Credited as resolved. change_statusreuses the existing_read_requests/_change_outcomepath;use()fails closed (unlistable account recorded without status, held by the spend gate).high_riskis true only becausesrc/adcp/__init__.pywas modified; the modification is an additive re-export and the reviewer found no medium-or-higher concern on it, so it is presumed safe (not an escalation trigger).gated_paths: falseandreview_decision: APPROVED; no no-auto-approve team match.
Decision table: no critical/high (row 1 n/a), gated_paths false (row 2 n/a), no deletions (row 3 n/a), no medium findings (rows 4/5/8 n/a), prior decision was approve not escalate (row 6 n/a), no team gate (row 7 n/a) → row 9 approve.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
The reviewer surfaced no Critical/High/Medium findings on this run. Walking the decision table top-down:
- Rows 1, 4, 6, 8: no findings at all this run — no trigger.
- Row 2:
gated_pathsis false (andreview_decisionisAPPROVEDanyway) — no trigger. - Rows 3/5:
high_riskis true, but the only reason issrc/adcp/__init__.py (modified), and there are no medium-or-higher findings on it, so the modification is presumed safe. No(deleted)entry. No trigger. - Row 7: no no-auto-approve team match.
- Falls through to row 9 → approve.
Subsequent-review note: the prior run's single Medium (accounts.py:661 — change() pending outcomes not tracked by refresh()) has been addressed. It is now documented-by-design at accounts.py L307-309 with change_status() as the explicit poll path, and the reviewer did not re-surface it. Net improvement.
Verified behavior of interest: use() now reads the account's real status from list_accounts (or an explicit status=) instead of hard-coding "active", and the spend gate at accounts.py:856,862 fails closed — an unlistable account yields status=None and is refused, and a payment_required account is refused. The new payment_required path has test coverage. Delta is clean.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean delta pass, no blocking findings.
This is a subsequent review on top of a prior approve. The delta since the last pass adds AccountChanges.change_status(account_id, request_id) in accounts.py, which composes the existing _read_requests and _change_outcome helpers and fail-closes to an AccountRejected value (ACCOUNT_REQUEST_NOT_FOUND) when the request is unknown — a sound fail-closed default, covered by a new round-trip test. Remaining delta is two doc notes, an alphabetical import reorder in __init__.py, and a wall-clock fix in the Postgres conformance fixture.
Gate checks:
high_risk: trueonly becausesrc/adcp/__init__.pyis(modified); the change is an alphabetical import reorder with no public-surface removal/rename/retyping, and the reviewer found no medium-or-higher concern — presumed safe (not row 5).gated_paths: false;review_decision: APPROVED.- No no-auto-approve team match.
- No critical/high/medium findings — rows 1, 4, 5, 8 do not fire.
- No
(deleted)reason (row 3 n/a). Prior decision wasapprove, so sticky row 6 n/a.
None of rows 1–8 fire → row 9 → approve.
Slice P15 of the reporting-subscriptions DX plan (
.context/dx-reporting-subscriptions.md§2.11, §6.2). It is independent of the reporting stack and targetsnextdirectly.API
Rules (beta.3
request_accountwithaccount_id)billing,billing_entity,payment_terms) needrevision=, the revision the change was approved against. Without it the call is refused before dispatch:AccountRequestError,dispatched=False,recovery="correctable", codeACCOUNT_REVISION_REQUIRED. The SDK never reads it for you, because that would defeat lost-update protection.effective_at.AccountUpdated.effective_atcarries beta.3's field: when the new money terms apply. Delivery before it is invoiced under the old terms.brandcountries,operator_unit,currency,timezone) readrevisionfromlist_accountswhen omitted. OnCONFLICTthey retry once, only if the fields being changed did not change concurrently. Otherwise the conflict is raised.allow_separate_accountdefaults toFalse, as in the spec. A rejection ofACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNTreads asoutcome.requires_new_account.AccountSeparateandAccountMatchedleave the registry's natural key on the old account.client.accounts.use(key, account_id)is the explicit step.AccountSeparatelistsnot_carried_over, andsubscriptions_not_covering: theaccount_ids-scoped reporting subscriptions that will not see the new account, read best effort throughclient.reporting.list_subscriptions().keyis the natural key the registry knows for the old account.ACCOUNT_CHANGE_INVALID) and sellers withoutaccount.requests(ACCOUNT_REQUESTS_UNSUPPORTED) are refused before dispatch.next_step()accepts change outcomes. Updated, separate and matched outcomes arenone.AccountUpdated,AccountSeparate,AccountMatchedandAccountChangeOutcomeare exported fromadcp, and the public API snapshot is regenerated (+4 names).Tests
tests/test_account_change.py:revisionsends nothing (pre-dispatch).revisionis updated, witheffective_at.not_carried_overand the old key, and the registry moves only throughuse().requires_new_account) outcomes.account.requestsare refused.The account outcomes, lifecycle and public-API suites are green. ruff and mypy are clean.
Docs: guide 4, Accounts: ensure and change, has Separate accounts and Money changes and revision written. The anchors are unchanged.
Not in this PR
The seller-side
accounts.requestplatform hook, with SDK-owned request records andaccount_request.changed(the seller half of T16), is not part of P15 as scoped for Python.🤖 Generated with Claude Code