Skip to content

feat(accounts): add accounts.change and use for account requests - #1777

Merged
bokelley merged 6 commits into
nextfrom
claude/issue-1695-p15-accounts-change
Nov 1, 2026
Merged

bokelley merged 6 commits into
nextfrom
claude/issue-1695-p15-accounts-change

Conversation

@bokelley

@bokelley bokelley commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

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 targets next directly.

API

outcome = await client.accounts.change("acc_nova", {"brand": {"domain": "nova.example", "countries": ["BE", "DE"]}},
                                       allow_separate_account=True)
match outcome:
    case AccountUpdated(revision=rev, effective_at=when): ...
    case AccountSeparate() as s: await client.accounts.use(s.key, s.account_id)
    case AccountMatched(): ...
    case AccountPending(action=action): ...
    case AccountRejected() as r if r.requires_new_account: ...

Rules (beta.3 request_account with account_id)

  • Money changes (billing, billing_entity, payment_terms) need revision=, the revision the change was approved against. Without it the call is refused before dispatch: AccountRequestError, dispatched=False, recovery="correctable", code ACCOUNT_REVISION_REQUIRED. The SDK never reads it for you, because that would defeat lost-update protection.
  • effective_at. AccountUpdated.effective_at carries beta.3's field: when the new money terms apply. Delivery before it is invoiced under the old terms.
  • Other changes (brand countries, operator_unit, currency, timezone) read revision from list_accounts when omitted. On CONFLICT they retry once, only if the fields being changed did not change concurrently. Otherwise the conflict is raised.
  • allow_separate_account defaults to False, as in the spec. A rejection of ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT reads as outcome.requires_new_account.
  • No silent re-point. AccountSeparate and AccountMatched leave the registry's natural key on the old account. client.accounts.use(key, account_id) is the explicit step.
    • AccountSeparate lists not_carried_over, and subscriptions_not_covering: the account_ids-scoped reporting subscriptions that will not see the new account, read best effort through client.reporting.list_subscriptions().
    • key is the natural key the registry knows for the old account.
  • Local refusals. Unknown attributes (ACCOUNT_CHANGE_INVALID) and sellers without account.requests (ACCOUNT_REQUESTS_UNSUPPORTED) are refused before dispatch.
  • next_step() accepts change outcomes. Updated, separate and matched outcomes are none.

AccountUpdated, AccountSeparate, AccountMatched and AccountChangeOutcome are exported from adcp, and the public API snapshot is regenerated (+4 names).

Tests

tests/test_account_change.py:

  • A money change without revision sends nothing (pre-dispatch).
  • A money change with revision is updated, with effective_at.
  • An automatic revision retries once on a concurrent bump, but not when the changed field moved.
  • A separate account carries not_carried_over and the old key, and the registry moves only through use().
  • Matched, pending and rejected (requires_new_account) outcomes.
  • Unknown fields and sellers without account.requests are 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.request platform hook, with SDK-owned request records and account_request.changed (the seller half of T16), is not part of P15 as scoped for Python.

🤖 Generated with Claude Code

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)
Comment thread src/adcp/accounts.py Resolved

@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 — 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.requests capability gate refuses unsupported sellers (Refused pathway, tested).
  • subscriptions_not_covering is 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>
Comment thread src/adcp/accounts.py Resolved

@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 — 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.json regenerated. No breaking change, no removed/renamed exports, no required→optional flips.
  • No generated-code hand-edits (src/adcp/types/domains/**, _generated.py untouched); 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_outcome resolution 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

bokelley and others added 2 commits November 1, 2026 18:38
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>

@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 — 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 from list_accounts (accounts.py:421-423) instead of hard-coding "active", closing the spend-gate status gap (now fail-closed, with a test asserting payment_required survives use()).

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 (and review_decision is 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, not escalate → row 6 does not apply.
  • Zero findings → rows 1, 4, 8 do not apply.

No rows 1–8 fire → row 9: approve.

@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 delta over the prior approve, no blocking or medium findings.

What I checked:

  • Adds accounts.change_status() and makes use() preserve the account's real status instead of stamping "active". New names are additive under feat(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_status plus the documented request_id contract — a deliberate consequence of the natural-key registry. Credited as resolved.
  • change_status reuses the existing _read_requests/_change_outcome path; use() fails closed (unlistable account recorded without status, held by the spend gate).
  • high_risk is true only because src/adcp/__init__.py was 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: false and review_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.

@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.

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_paths is false (and review_decision is APPROVED anyway) — no trigger.
  • Rows 3/5: high_risk is true, but the only reason is src/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>

@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 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: true only because src/adcp/__init__.py is (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 was approve, so sticky row 6 n/a.

None of rows 1–8 fire → row 9 → approve.

@bokelley
bokelley merged commit 6207e4c into next Nov 1, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1695-p15-accounts-change branch November 1, 2026 22:13
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