Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f20377c
feat(protocol): add account change feed draft
bokelley Aug 24, 2026
88a5a40
fix(protocol): gate account change feed scenario
bokelley Aug 24, 2026
7f6da88
feat(training): model externally added creatives
bokelley Aug 24, 2026
9219c0d
Merge remote-tracking branch 'origin/main' into seller-media-source-o…
bokelley Aug 24, 2026
e48ea36
test(training): verify account change fanout
bokelley Aug 24, 2026
716c5d8
fix(protocol): harden account change completeness
bokelley Aug 24, 2026
8dad354
fix(protocol): address account change feed review
bokelley Aug 25, 2026
fd1634b
Merge remote-tracking branch 'origin/main' into seller-media-source-o…
bokelley Aug 25, 2026
8a0f887
test(protocol): close change feed merge gates
bokelley Aug 25, 2026
ed2c09d
Merge remote-tracking branch 'origin/main' into seller-media-source-o…
bokelley Aug 25, 2026
dd77bfc
Merge remote-tracking branch 'origin/main' into seller-media-source-o…
bokelley Aug 25, 2026
d9c39ef
test(training): update controller scenario inventory
bokelley Aug 25, 2026
7f38b46
fix(training): scope change-feed cursor expiry
bokelley Aug 25, 2026
71deeed
fix(training): clarify governance fixture inspection
bokelley Aug 25, 2026
800f824
fix(training): avoid auth heuristic for fixture
bokelley Aug 25, 2026
c94476b
fix(training): preserve controller creative fixtures
bokelley Aug 25, 2026
d10a24c
Merge remote-tracking branch 'origin/main' into seller-media-source-o…
bokelley Aug 25, 2026
a04d26a
fix(training): preserve frozen creative fixtures
bokelley Aug 25, 2026
69a91dd
fix(training): narrow creative fixture fallback
bokelley Aug 25, 2026
e4a4b1e
Merge remote-tracking branch 'origin/main' into seller-media-source-o…
bokelley Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-account-change-feed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"adcontextprotocol": minor
---

Add the AdCP 3.2 draft account change feed from RFC #6810: source-neutral authoritative reads, `list_account_changes`, `account.change_recorded`, account-specific connected-source coverage, 90-day retention, explicit cursor-expiry recovery, capability-gated conformance, and a shared-account training lab. The draft remains blocked on RFC ratification before merge.
171 changes: 171 additions & 0 deletions docs/accounts/tasks/list_account_changes.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
---
title: list_account_changes
description: "Read the durable ordered feed of material changes to authoritative state on one shared advertiser account."
"og:title": "AdCP — list_account_changes"
testable: false
---

`list_account_changes` returns material changes to AdCP-visible state on one
account, including changes made outside the observing buyer's calls. Use it
after a snapshot bootstrap, after receiving `account.change_recorded`, or for
an account-level audit of state transitions.

<Warning>
This is a 3.2 implementation draft for [RFC #6810](https://github.com/adcontextprotocol/adcp/issues/6810). It is not normative until the RFC is ratified.
</Warning>

The feed is optional and appears only when
`get_adcp_capabilities.account.change_feed.supported` is `true`.

## What the feed covers

For every resource type the seller advertises, a material committed change
must both appear here and be reflected on its authoritative read, regardless
of whether it originated through AdCP, a seller operator or system, another
authorized principal, or a connected platform.

The feed describes control-plane changes: creation, configuration, lifecycle,
relationships, reporting corrections, and deletion markers. It excludes
reads, failed operations, dry runs, exact idempotency replays, delivery counter
increments, raw audience members, raw events, catalog item bodies, and webhook
delivery attempts.

For the complete coverage matrix and rationale, see the
[account change feed draft](https://github.com/adcontextprotocol/adcp/blob/main/specs/account-change-feed.md).

## Request

**Request schema:** [`account/list-account-changes-request.json`](https://adcontextprotocol.org/schemas/latest/account/list-account-changes-request.json)

| Field | Required | Description |
| --- | --- | --- |
| `account` | Yes | Exactly one account reference. |
| `cursor` | No | Opaque checkpoint from an earlier call under the same principal, authorization epoch, account, and filters. Mutually exclusive with `starting_position`. |
| `starting_position` | No | `earliest` (intentional completeness-first default) or `latest`. Use `latest` before a snapshot bootstrap. |
| `resource_types` | No | Exact resource-type filter. The cursor is bound to the normalized filter. |
| `max_results` | No | 1–100 changes; default 50. |

Acquire a high-water checkpoint before reading several snapshots:

```json
{
"adcp_version": "3.2",
"account": { "account_id": "acc_luma_shared" },
"starting_position": "latest",
"max_results": 100
}
```

## Response

**Response schema:** [`account/list-account-changes-response.json`](https://adcontextprotocol.org/schemas/latest/account/list-account-changes-response.json)

| Field | Description |
| --- | --- |
| `changes` | Oldest-first material change records. |
| `cursor` | Checkpoint after the scanned high-water. Always present, even on an empty tail page. |
| `has_more` | Whether more retained matching records were available when the page was generated. |
| `available_since` | Current retention boundary for this caller and account. |
| `generated_at` | Seller time for the page and coverage watermarks. |
| `source_coverage` | Optional per-account connected-source status and mechanically evaluable freshness. A connected source reporting `current` supplies `last_successful_sync_at` and `stale_after_seconds`. |

Each change record names the changed resource, action, server-derived origin,
optional changed paths and revision, and an allowlisted read-only
`repair.task` hint. It deliberately does not include full before/after values.

```json
{
"changes": [
{
"change_id": "chg_01K38G7X8ZGX9T4F1Q5W6Y2M3N",
"recorded_at": "2026-08-24T11:58:04Z",
"resource": {
"type": "creative",
"account_id": "acc_luma_shared",
"resource_id": "cr_8421"
},
"action": "updated",
"origin": {
"kind": "connected_platform",
"connection_id": "conn_social_primary"
},
"changed_paths": ["/name", "/assets/0"],
"repair": { "task": "list_creatives" }
}
],
"cursor": "opaque-checkpoint",
"has_more": false,
"available_since": "2026-05-26T00:00:00Z",
"generated_at": "2026-08-24T12:00:00Z"
}
```

## Cursor contract

Cursor order, not timestamps, defines the total order. Appends never reorder
prior pages. A filtered empty page still advances across scanned nonmatching
records, so persist every returned cursor.

A cursor is scoped to the authenticated principal, resolved account, and
normalized filters. Do not move it between credentials or filter sets. Sellers
MUST reject a principal, account, or filter mismatch with [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) at
`cursor`; applying new filters at an old position could permanently skip
history.

The cursor also binds an authorization-scope epoch. If visible resources for
the principal expand or contract, the seller returns [`CURSOR_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-cursor-expired) with
`details.reason: "authorization_scope_changed"`. Rebootstrap snapshots so
changes skipped under the old visibility cannot become an invisible gap.

Sellers retain changes for at least 90 days. An expired cursor returns
[`CURSOR_EXPIRED`](/docs/building/verification/compliance-catalog#error-code-cursor-expired); it never silently restarts. Recovery is to obtain a new
`latest` checkpoint, rebuild all authoritative snapshots, then drain changes
after the checkpoint.

## Race-free bootstrap

1. Register `account.change_recorded` through [`sync_accounts`](/docs/accounts/tasks/sync_accounts#account-change-feed-notifications).
2. Obtain C0 with `starting_position: "latest"`.
3. Enumerate [`list_accounts`](/docs/accounts/tasks/list_accounts), [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys), [`list_creatives`](/docs/creative/task-reference/list_creatives), financials,
delivery, and other advertised reads for the account. Include all lifecycle
statuses and exhaust every page.
4. Drain changes after C0. Locally allowlist `repair.task`, construct and
validate the read request from the authenticated account and `resource`
identity, then invoke it. Never dispatch feed-supplied task arguments.
5. Persist the returned cursor and repeat after each signed notification.
6. Poll periodically so a missed webhook does not create a gap.

The webhook's optional `through_cursor` is only a target watermark. Do not
install it without reading all intervening pages.

## Change granularity

The feed contains at least one record per independently repairable identity.
Several package or assignment changes may coalesce under their media buy when
[`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) repairs the complete changed closure; changes to independent
creatives require separate records. Records from one operation may share
`batch_id`, but retain independent `change_id` values and notifications.

For seller-mediated mutations, snapshot and records commit atomically. For an
external platform, records commit with the seller's ingestion of the observed
change. A pass-through authoritative read may lead the feed only within the
source's declared `stale_after_seconds` freshness bound.

## Shared-account example

Suppose a connected platform changes `cr_8421` while the buyer is idle. The
seller records the creative revision, makes it visible on [`list_creatives`](/docs/creative/task-reference/list_creatives),
appends the account change, and then fires `account.change_recorded`. The buyer
drains from its cursor and rereads `list_creatives`; it does not treat the
webhook payload as the creative document.

This is the same flow for a seller operator changing a campaign budget or
another authorized buyer pausing a package. Account visibility is based on
current authorization, not on which principal created the resource.

## Relationship to `webhook_activity`

`webhook_activity[]` answers whether a webhook delivery was attempted. This
feed answers which material business-state changes were recorded. A change can
exist even when a webhook is missed; one change can also have several delivery
attempts. Never use the transport log as account history.
7 changes: 6 additions & 1 deletion docs/accounts/tasks/list_accounts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ testable: false

Returns all accounts the authenticated agent can operate on this vendor agent. Use this to discover existing accounts, check status changes on pending accounts, and recover the exact account reference expected on protocol operations.

Account visibility is source-neutral: the seller MUST return every account the
authenticated caller can operate, including pre-existing accounts created or
managed through seller and connected-platform surfaces. `list_accounts` is not
limited to relationships first provisioned through [`sync_accounts`](/docs/accounts/tasks/sync_accounts).

For upstream-managed account namespaces, `list_accounts` is not optional discovery polish; it is the namespace discovery contract. The upstream platform owns the accessible account set, so buyers MUST resolve an explicit `account_id` before the first account-scoped request. If the authenticated credential can access more than one account, the seller MUST expose `list_accounts`; if it can access exactly one account, the seller SHOULD expose `list_accounts` returning that singleton so SDKs can auto-select it and still send `{ "account_id": "..." }` on required-account calls. [`sync_accounts`](/docs/accounts/tasks/sync_accounts) provisioning does not create account-id accounts in 3.0.x unless a future explicit capability declares that mode; if `sync_accounts` is exposed on these sellers today, use it only for settings updates against an account already identified by `account_id`.

`list_accounts` works across all vendor protocols — media buy agents, signals agents, governance agents, and creative agents all return accounts through this same task.
Expand Down Expand Up @@ -104,7 +109,7 @@ seller-assigned `account_id` or by the complete buyer-declared natural key.
| `governance_agents` | Governance agent endpoints registered on this account. Present when governance agents have been configured via [`sync_governance`](/docs/accounts/tasks/sync_governance). |
| `setup` | Present when `status: "pending_approval"`. Contains `url` for completing setup and `message` explaining what's needed. |
| `authorization` | Optional. The calling agent's scope grant for this account — `allowed_tasks`, `field_scopes`, `scope_name`, `read_only`. Applies to every vendor agent type (media-buy, signals, governance, creative, brand) — the Accounts Protocol surface is shared. Vendor agents that support scope introspection SHOULD populate this; media-buy sales agents claiming the `attestation_verifier` standard scope MUST populate it. Absence means the vendor agent does not advertise introspectable scope for this account; callers MUST NOT infer access from absence and fall back to error-driven discovery via the RBAC error codes. See [Caller authorization](/docs/accounts/overview#caller-authorization) for the full shape and semantics. |
| `notification_configs` | Account-level webhook subscribers registered via [`sync_accounts`](/docs/accounts/tasks/sync_accounts#account-level-webhook-subscriptions). Each entry carries `subscriber_id`, `url`, `event_types[]`, and `active`. Present when the account has any persisted subscribers. `subscriber_id` is the account-scoped logical key; re-registering the same subscriber replaces that subscriber's config. `authentication.credentials` is omitted on every entry (write-only). Use this surface to verify what's active after a sync, audit fan-out across multiple subscribers, and detect drift between buyer-side expectations and seller-side persisted state. `account.status_changed` subscribers receive status invalidation fires and repair by re-reading this `status` field. |
| `notification_configs` | Account-level webhook subscribers registered via [`sync_accounts`](/docs/accounts/tasks/sync_accounts#account-level-webhook-subscriptions). Each entry carries `subscriber_id`, `url`, `event_types[]`, and `active`. Present when the account has any persisted subscribers. `subscriber_id` is the account-scoped logical key; re-registering the same subscriber replaces that subscriber's config. `authentication.credentials` is omitted on every entry (write-only). Use this surface to verify what's active after a sync, audit fan-out across multiple subscribers, and detect drift between buyer-side expectations and seller-side persisted state. `account.status_changed` subscribers repair by rereading this account; `account.change_recorded` subscribers drain [`list_account_changes`](/docs/accounts/tasks/list_account_changes) and invoke each record's repair task. |
| `webhook_activity` | Optional recent webhook delivery attempts for this account, returned when `include_webhook_activity: true` and the seller exposes the debug log. Omitted means unsupported or not requested; `[]` means supported but no retained fires; non-empty records are most recent first. |

For buyer-declared accounts, `list_accounts` MUST return the current canonical natural-key fields needed to use the account again. A stateless buyer can therefore take `brand`, `operator`, `operator_unit`, `currency`, buyer-selected `timezone`, and `sandbox` from the response and send the same shape as `account` on a later task. `operator_unit.name` may change without changing which account the key identifies. A requested identity in `identity_change` is not a usable account reference until it becomes canonical.
Expand Down
20 changes: 19 additions & 1 deletion docs/accounts/tasks/sync_accounts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,24 @@ Indicator and assignment subscriptions are prospective: activation or reactivati

Before relying on durable account lifecycle webhooks, read `get_adcp_capabilities.account.notifications`. Sellers that declare `supported: true` accept `account.status_changed` registrations here, name `sync_accounts` as the registration task, and name [`list_accounts`](/docs/accounts/tasks/list_accounts) as the repair read. Sellers that omit the capability or declare `supported: false` MUST reject `account.status_changed` registrations instead of silently storing a subscriber that will never fire.

### Account change feed notifications

`account.change_recorded` is the generic wake-up for the optional durable
[`list_account_changes`](/docs/accounts/tasks/list_account_changes) feed. It
covers committed material changes from every origin within the seller's
advertised resource coverage, including seller surfaces, connected platforms,
seller automation, and other authorized principals. Before accepting this
event type, the seller MUST advertise
`get_adcp_capabilities.account.change_feed.supported: true`.

Each feed record produces one logical fire for every active subscriber that
requested `account.change_recorded`. The fire's `notification_id` equals the
record's `change_id`. The payload is only an invalidation: receivers drain from
their own persisted cursor and then invoke the record's repair task. Its
optional `through_cursor` is a target watermark, never a checkpoint to install
without reading intervening pages. Existing specialized notifications may
overlap this generic fire.

For these event types, "wholesale feed" means the seller's buyable wholesale product and signals feeds returned by [`get_products`](/docs/media-buy/task-reference/get_products) or [`get_signals`](/docs/signals/tasks/get_signals); it is not the buyer-provided feeds managed by [`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs).

Permitted in **both** provisioning and settings-update modes. Declarative semantics:
Expand All @@ -289,7 +307,7 @@ Each entry has:

- `subscriber_id` — buyer-supplied identifier, unique within the account; echoed on every fire so multi-subscriber accounts can route by endpoint
- `url` — HTTPS endpoint URL. Sellers MUST complete an endpoint activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active.
- `event_types[]` — types the subscriber wants. Only account-anchored types are permitted (today: `creative.status_changed`, `creative.assignment_changed`, `indicators.changed`, `creative.purged`, `account.status_changed`, `product.created`, `product.updated`, `product.priced`, `product.removed`, `signal.created`, `signal.updated`, `signal.priced`, `signal.removed`, `wholesale_feed.bulk_change`). Sellers MUST reject any media-buy-anchored type (`scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) or agent-anchored type (`capabilities.changed`) as a per-account validation failure with [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) or [`VALIDATION_ERROR`](/docs/building/verification/compliance-catalog#error-code-validation-error) in `accounts[].errors[]`, and `error.field` MUST point at the invalid `event_types` entry.
- `event_types[]` — types the subscriber wants. Only account-anchored types are permitted (today: `creative.status_changed`, `creative.assignment_changed`, `indicators.changed`, `creative.purged`, `account.status_changed`, `account.change_recorded`, `product.created`, `product.updated`, `product.priced`, `product.removed`, `signal.created`, `signal.updated`, `signal.priced`, `signal.removed`, `wholesale_feed.bulk_change`). Sellers MUST reject any media-buy-anchored type (`scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) or agent-anchored type (`capabilities.changed`) as a per-account validation failure with [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) or [`VALIDATION_ERROR`](/docs/building/verification/compliance-catalog#error-code-validation-error) in `accounts[].errors[]`, and `error.field` MUST point at the invalid `event_types` entry.
- `product_payload_view` — `canonical` for a [`list_products`](/docs/media-buy/task-reference/list_products) mirror or `legacy` for the 3.x `get_products` shape. Omission defaults to `legacy`; valid only when a product event is selected.
- `authentication` (optional) — legacy Bearer or HMAC-SHA256. Omit to use the default RFC 9421 webhook profile. When present, the same signed-registration downgrade-resistance rules as `push_notification_config.authentication` apply. Credentials are write-only — sellers omit them on reads.
- `active` (default `true`) — set `false` to pause a subscriber without removing the registration. Sellers MAY skip only the outbound proof challenge while `active: false`; they MUST still enforce HTTPS parsing, hostname normalization, and reserved-range rejection on write. Paused subscribers MUST NOT receive fires until reactivated. Reactivation MUST repeat full SSRF validation with connect pinning plus proof-of-control for any tuple without current valid proof.
Expand Down
Loading
Loading