Skip to content

feat(accounts)!: enforce change feed changed_paths and per-reader idempotency_key (#1647) - #1805

Merged
bokelley merged 1 commit into
nextfrom
claude/issue-1647-account-change-feed
Nov 2, 2026
Merged

bokelley merged 1 commit into
nextfrom
claude/issue-1647-account-change-feed

Conversation

@bokelley

@bokelley bokelley commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Spec

AdCP 3.3 (core/account-change.json), for sellers declaring the experimental account.change_feed:

  • Records whose origin.kind is adcp or seller_operator MUST carry non-empty changed_paths (RFC 6901 pointers; "" for a whole-resource create, delete or purge).
  • idempotency_key is echoed only on adcp records, and only to the principal that sent the originating request. Every other reader gets the record without it, and account.change_recorded notifications MUST NOT carry it.
  • Creatives may carry seller_revision on list_creatives; a creative change record's resource_revision mirrors it.

The Scope3 ESA already sends changed_paths; the SDK now matches and enforces it.

Design

New module adcp.account_change_feed (its own public address; not added to the adcp top-level facade, so the API snapshot is unchanged):

Helper Purpose
ROOT_POINTER, json_pointer(*tokens) Build RFC 6901 pointers (escapes ~ and /).
validate_account_change(change) Raises AccountChangeRecordError (a ValueError with .pointer) for missing or empty changed_paths on adcp / seller_operator records, or idempotency_key on a non-adcp record.
AttributedAccountChange, attribute_account_change(change, originator) An AccountChange subclass whose originating PrincipalRef is a pydantic private attribute, so it never reaches the wire. originator can be a PrincipalRef, a principal id, or the mutating request's ToolContext.
project_account_change / project_list_account_changes_response Projects records for one reader.
account_change_recorded_payload(change, subscriber_id=...) Builds the notification body from a record. Never copies the record's key.
mirror_creative_revision(change, creative) Copies seller_revision into resource_revision in the same JSON type. Removes it when the creative has none, and checks the bounds.

Responses (list_account_changes). create_tool_caller serves both MCP and A2A. Right after the handler returns, while attribution is still on the model instances, it projects the page for the caller (ToolContext.tenant_id + caller_identity). Projection runs before model_dump, context echo, the response enhancer and version conversion.

  • An adcp record attributed to the caller keeps idempotency_key. Every other record loses it: plain dicts, unattributed models, records with a different principal or tenant, and anonymous callers. If the SDK can't show the reader is the originator, the key is stripped.
  • A non-adcp record carrying a key has it dropped, not refused.
  • An adcp / seller_operator record without changed_paths refuses the page with VALIDATION_ERROR (field /changes/N/changed_paths, details.side = "response") and an error log on the server. This applies in every validation mode, for handlers whose application version is 3.3 or later. A handler pinned to 3.2, where changed_paths is optional, isn't checked.
  • Why refuse rather than log: strict response validation is already the default and refuses these pages, because the bundled 3.3 schema encodes the rule as if/then. The SDK check makes that refusal deterministic and points at the exact field. A buyer reconciles its cache from changed_paths, so a record without it is worse than a failed read: the buyer would retry a failed read, but would silently mis-reconcile from a bad record. The feed is an experimental 3.3-only surface with no SDK 9 adopters, and the one known adopter (ESA) already conforms.

Notifications. WebhookSender.prepare_account_change_recorded / send_account_change_recorded now also accept the change record itself, plus subscriber_id= (required) and through_cursor=. A record is detected by origin / repair, which the notification schema forbids. When given a record, the SDK:

  • refuses it if it breaks the rules (AccountChangeRecordError);
  • builds the body via account_change_recorded_payload, which never copies the record's idempotency_key;
  • uses the explicit delivery key if given, otherwise mints a fresh one, and refuses a delivery key equal to the originating request's key.

Notification-shaped payloads pass through unchanged. Their idempotency_key is the delivery key, which the SDK can't tell apart from a copied request key; the docstring warns against copying one. _notification_envelope.py needed no change.

seller_revision. It already passes through list_creatives unharmed: the model round-trips it, and server serialization at 3.3 keeps it. For buyers below 3.3, the existing 3.3 → 3.2 conversion drops it and records the loss. The same conversion drops a record's idempotency_key for 3.2 buyers. Tests pin both behaviors. The stable_creative_ids / platform_id work (#1638) is untouched.

Tests

tests/test_account_change_feed.py (41 tests):

  • pointer escaping
  • record rules, cross-checked against the bundled 3.3 schema
  • attribution stays off the wire
  • the per-reader echo matrix (same principal, other principal, tenant mismatch, anonymous, unattributed)
  • dispatch through create_tool_caller with model and dict responses under strict validation
  • refusal under strict validation and with validation disabled
  • a 3.2-pinned handler
  • notification preparation from records (fresh key, key reuse refused, missing subscriber_id, non-conformant record)
  • seller_revision passthrough at 3.3 and its drop at 3.2
  • mirror_creative_revision

Docs

  • docs/adcp-3.3-sdk-inventory.md: the list_creatives seller_revision row and the account-change.json row are marked done.
  • MIGRATION_v9_to_v10.md: new row covering refused pages, stripped keys and the record-accepting notification preparation.

Closes #1647

BREAKING CHANGE: a list_account_changes page with an adcp or seller_operator record that lacks a non-empty changed_paths is refused with VALIDATION_ERROR, and idempotency_key is stripped from every record not served to its originating principal; see MIGRATION_v9_to_v10.md.

🤖 Generated with Claude Code

@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 AdCP 3.3 account change feed (#1647) with no blocking or medium findings.

What I checked (per reviewer):

  • account_change_feed.py key-echo is fail-closed: _echoes_key_to (L255-261) returns False unless record is AttributedAccountChange with origin.kind=="adcp" and originator==reader; project_account_change (L280-281) strips idempotency_key for plain dicts, unattributed models, and mismatched/anonymous readers. PrincipalRef equality includes tenant, so cross-tenant collisions strip too.
  • _requires_changed_paths fail-closes to True on None/unparseable input; exempts 3.2, admits 3.3/future majors.
  • Dispatch centralized in create_tool_caller (MCP + A2A), runs before model_dump/context-echo/version-conversion, raises VALIDATION_ERROR in all modes.
  • Webhook timeout/retry path unchanged; projection is sync CPU with typed-error on refusal.
  • Semver: BREAKING CHANGE footer + migration row present; submodule-level change, API snapshot unchanged.

Decision trace: No critical/high/medium findings (row 1, 4, 5, 6 don't fire). gated_paths false (row 2 n/a). high_risk true only via src/adcp/server/mcp_tools.py (modified), but with zero medium findings row 5 doesn't fire and no deletion means row 3 doesn't fire. No no-auto-approve team match (row 7 n/a). Zero medium findings (row 8 n/a). Falls through to row 9 → approve.

…mpotency_key (#1647)

AdCP 3.3 account change feed rules, applied where the SDK emits records:

- list_account_changes dispatch refuses a page carrying an adcp /
  seller_operator record without non-empty changed_paths
  (VALIDATION_ERROR at /changes/N/changed_paths, any validation mode,
  handlers on 3.3 or later).
- Every record loses idempotency_key unless it is an adcp record
  attributed, off the wire, to the calling principal
  (attribute_account_change); unattributed records fail closed.
- WebhookSender.prepare_account_change_recorded accepts the change
  record (with subscriber_id), checks it, and never carries its key.
- New adcp.account_change_feed helpers: json_pointer, ROOT_POINTER,
  validate_account_change, attribute_account_change,
  project_list_account_changes_response, account_change_recorded_payload,
  mirror_creative_revision (creative seller_revision -> resource_revision).

BREAKING CHANGE: a list_account_changes handler returning an adcp or
seller_operator record without changed_paths now fails the call, and
idempotency_key is stripped from every record not attributed to the
caller via adcp.account_change_feed.attribute_account_change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bokelley
bokelley force-pushed the claude/issue-1647-account-change-feed branch from a521a86 to f119e57 Compare November 2, 2026 02:46

@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 pass, no findings.

Checked:

  • New adcp.account_change_feed module enforces AdCP 3.3 change-feed rules: changed_paths required on adcp/seller_operator records, per-reader idempotency_key projection.
  • Key-echo gate (account_change_feed.py:255-261, strip at :280-281) fails closed — plain dicts, unattributed models, tenant mismatches and anonymous callers all lose the key; covered by the echo matrix and fail-closed tests.
  • Semver signal correct: feat!: + BREAKING CHANGE footer + MIGRATION_v9_to_v10.md row.
  • Import layering clean (domain path, no _generated), forward-compat files untouched.
  • Version-pathway handled: projection runs before model_dump/version-conversion so attribution survives; 3.2-handler path keeps paths optional but still strips keys. Test present.
  • CI gates armed.

Decision table: no critical/high/medium findings. gated_paths is false; review_decision is APPROVED. high_risk true but only src/adcp/server/mcp_tools.py (modified) with no medium-or-higher concern, so row 5 does not fire; no deletions (row 3 n/a). No author team gate. Prior decision was approve. Falls through to row 9 → approve.

@bokelley
bokelley merged commit d9ba90c into next Nov 2, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1647-account-change-feed branch November 2, 2026 04:24
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