diff --git a/.changeset/add-account-change-feed.md b/.changeset/add-account-change-feed.md
new file mode 100644
index 0000000000..284cec4f3d
--- /dev/null
+++ b/.changeset/add-account-change-feed.md
@@ -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.
diff --git a/docs/accounts/tasks/list_account_changes.mdx b/docs/accounts/tasks/list_account_changes.mdx
new file mode 100644
index 0000000000..648ffdb384
--- /dev/null
+++ b/docs/accounts/tasks/list_account_changes.mdx
@@ -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.
+
+
+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.
+
+
+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.
diff --git a/docs/accounts/tasks/list_accounts.mdx b/docs/accounts/tasks/list_accounts.mdx
index cb72808624..deba15f2b1 100644
--- a/docs/accounts/tasks/list_accounts.mdx
+++ b/docs/accounts/tasks/list_accounts.mdx
@@ -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.
@@ -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.
diff --git a/docs/accounts/tasks/sync_accounts.mdx b/docs/accounts/tasks/sync_accounts.mdx
index ed3ee8de5a..5ebe719b2c 100644
--- a/docs/accounts/tasks/sync_accounts.mdx
+++ b/docs/accounts/tasks/sync_accounts.mdx
@@ -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:
@@ -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.
diff --git a/docs/creative/task-reference/list_creatives.mdx b/docs/creative/task-reference/list_creatives.mdx
index c8946ca014..bf3c091b91 100644
--- a/docs/creative/task-reference/list_creatives.mdx
+++ b/docs/creative/task-reference/list_creatives.mdx
@@ -7,6 +7,13 @@ description: "list_creatives browses and filters creatives in an AdCP library by
Browse and filter creatives in a creative library. Supports filtering by asset type, format, status, concept, tags, date range, and dynamic variables, with pagination and optional field enrichment.
+For every account the caller can access, the library membership is
+**source-neutral**: sellers MUST return creatives the caller can see regardless
+of whether they were created through [`sync_creatives`](/docs/creative/task-reference/sync_creatives), a seller UI or API,
+seller automation, another authorized principal, or a connected platform.
+Filters and lifecycle defaults still apply. An implementation that exposes
+only AdCP-created creatives is not a conforming account snapshot.
+
Implemented by any agent that hosts a creative library — creative agents (ad servers, creative management platforms) and sales agents that manage creatives.
**Response time**: ~1 second (simple database lookup)
@@ -22,6 +29,7 @@ Implemented by any agent that hosts a creative library — creative agents (ad s
- Filter by creative concept (groups of related creatives across sizes/formats)
- Find DCO creatives and inspect their dynamic content slots
- Find creatives with seller indicators such as package-scoped creative fatigue
+- Reconcile source-neutral external changes through the optional account change feed
## Request parameters
diff --git a/docs/learning/shared-account-change-feed.mdx b/docs/learning/shared-account-change-feed.mdx
new file mode 100644
index 0000000000..9bc5baf864
--- /dev/null
+++ b/docs/learning/shared-account-change-feed.mdx
@@ -0,0 +1,188 @@
+---
+title: "Lab: reconcile a shared account"
+sidebarTitle: "Shared-account changes"
+description: "Connect to an existing seller account, observe changes initiated elsewhere, drain the durable account feed, and repair authoritative state."
+"og:title": "AdCP — Shared-account change feed lab"
+---
+
+# Reconcile a shared seller account
+
+Real advertiser accounts are not exclusively managed by one buyer agent. A
+seller operator, another authorized buyer, automation, or a connected platform
+can change the same campaigns and creatives while your agent is idle.
+
+This lab teaches the buyer convergence loop:
+
+1. subscribe to `account.change_recorded`;
+2. acquire a latest checkpoint before snapshotting;
+3. enumerate authoritative state;
+4. let the training seller simulate external creation and modification;
+5. drain `list_account_changes`; and
+6. locally allowlist `repair.task`, construct a validated request from the
+ authenticated account and resource identity, and reread the resource.
+
+
+This lab exercises the AdCP 3.2 draft in [RFC #6810](https://github.com/adcontextprotocol/adcp/issues/6810). The surface can change before ratification.
+
+
+
+The hosted training seller does not advertise this capability until its
+durable server-side feed store is enabled. The reference scenario remains
+available to local and compliance runners; a production deployment must not
+claim the 90-day retention guarantee with process-local state.
+
+
+## Learning objectives
+
+By the end, you can:
+
+- explain why task responses are insufficient on a shared account;
+- distinguish current snapshots, durable business changes, notification
+ wake-ups, and `webhook_activity[]` transport diagnostics;
+- close the multi-read bootstrap race with a `latest` checkpoint;
+- process creation and modification your agent did not initiate; and
+- recover from a missed webhook or `CURSOR_EXPIRED` without accepting stale
+ state.
+
+## Connect to the training seller
+
+After the hosted seller advertises `account.change_feed`, use a caller-unique API key from the
+[AgenticAdvertising.org dashboard](https://agenticadvertising.org/dashboard).
+Durable feed state and cursors are principal-scoped, so a shared public key is
+not suitable for this exercise.
+
+```bash
+export AGENT_URL="https://test-agent.adcontextprotocol.org/sales/mcp"
+export ADCP_AUTH_TOKEN=""
+```
+
+Use this existing shared sandbox account throughout:
+
+```json
+{
+ "account_id": "acc_luma_shared"
+}
+```
+
+The account is deliberately not buyer-exclusive. The training seller models a
+generic connected platform that can modify its AdCP-visible resources.
+
+## Exercise
+
+### 1. Confirm capability and register the wake-up
+
+Call `get_adcp_capabilities` and require:
+
+```json
+{
+ "account": {
+ "change_feed": {
+ "supported": true,
+ "read_task": "list_account_changes",
+ "registration_task": "sync_accounts",
+ "event_type": "account.change_recorded"
+ }
+ }
+}
+```
+
+Use `sync_accounts` settings-update mode with
+`accounts[0].account.account_id: "acc_luma_shared"` and
+`accounts[0].notification_configs[]` to register an HTTPS endpoint for
+`account.change_recorded`. The seller proves control before the subscription
+becomes active. Persist the returned subscriber configuration.
+
+### 2. Acquire C0 and snapshot
+
+Before reading any account resources, call:
+
+```json
+{
+ "account": {
+ "account_id": "acc_luma_shared"
+ },
+ "starting_position": "latest",
+ "max_results": 100
+}
+```
+
+Persist the returned cursor as C0 even when `changes` is empty. Then enumerate
+`list_accounts`, `get_media_buys`, and `list_creatives`, including every status
+and page. A default active-only media-buy read is not a complete baseline.
+
+### 3. Simulate activity outside your agent
+
+Call `comply_test_controller` with `scenario: "seed_creative"`, a new,
+run-unique `creative_id`, and a complete image creative fixture. This
+represents the connected platform adding a creative while your buyer agent is
+idle; do not call `sync_creatives` first. A run-unique ID keeps repeated lab
+runs independent while preserving the controller's seed idempotency contract.
+
+The training seller first commits the new creative and account change
+record, then emits `account.change_recorded` to your active endpoint. The
+webhook's `notification_id` equals its `change_id`.
+
+### 4. Drain and repair
+
+Call `list_account_changes` with C0. Inspect:
+
+- `resource.type` and `resource.resource_id`;
+- `origin.kind: "connected_platform"`;
+- `changed_paths`;
+- `repair.task: "list_creatives"`; and
+- the new returned cursor.
+
+Now call `list_creatives` for the resource ID and accept that response as
+current truth. Do not reconstruct the creative from webhook or change-record
+metadata, and never execute a feed-supplied task name or arguments directly.
+An unknown repair hint requires a safe full account rescan.
+
+### 5. Observe an external modification
+
+Using the same creative ID, call `comply_test_controller` with
+`scenario: "force_creative_status"`, `status: "rejected"`, and a categorical
+rejection reason. This represents connected-platform policy review after the
+creative was added.
+
+Verify that a second `account.change_recorded` wake-up arrives even though the
+buyer called no creative mutation task. Drain from the cursor returned in step
+4 and require a `status_changed` record with
+`origin.kind: "connected_platform"`. Reread `list_creatives` and confirm that
+the authoritative status is now `rejected`.
+
+### 6. Prove tail and recovery behavior
+
+Call `list_account_changes` again with the new cursor. A caught-up response has
+`changes: []`, `has_more: false`, and still returns a cursor. Persist it.
+
+Then explain both recovery cases:
+
+- **Webhook missed:** poll from the persisted cursor; the durable feed closes
+ the gap.
+- **`CURSOR_EXPIRED`:** acquire a new latest checkpoint, rebuild every
+ authoritative snapshot, then drain after that checkpoint. Never silently
+ restart from the oldest retained record and pretend the gap is complete.
+
+## Assessment
+
+You pass when you can demonstrate externally initiated creation and
+modification end to end and correctly answer:
+
+1. Which surface is authoritative current state?
+2. Why is `webhook_activity[]` not the account change feed?
+3. Why must the cursor exist on an empty response?
+4. What does `has_more: false` mean when a connected source is unavailable?
+5. Which data must never appear in a change record?
+
+The expected answers are: the locally allowlisted repair read; transport attempts are not
+business changes; the empty cursor is the resumable tail checkpoint; caught up
+to seller ingestion is not necessarily caught up to an unavailable upstream;
+and credentials, financial account details, raw audience members/events, and
+unbounded resource payloads stay out of the feed.
+
+## Related reading
+
+- [`list_account_changes`](/docs/accounts/tasks/list_account_changes)
+- [Snapshot and log](/docs/protocol/snapshot-and-log)
+- [`sync_accounts` account subscriptions](/docs/accounts/tasks/sync_accounts#account-change-feed-notifications)
+- [`list_creatives`](/docs/creative/task-reference/list_creatives)
diff --git a/docs/learning/specialist/media-buy.mdx b/docs/learning/specialist/media-buy.mdx
index 41dcf76b3b..48ab0a179e 100644
--- a/docs/learning/specialist/media-buy.mdx
+++ b/docs/learning/specialist/media-buy.mdx
@@ -226,6 +226,7 @@ During the module, Addie will guide you through hands-on exercises:
8. **Broadcast billing and delivery** — Create a broadcast buy with a buy-level `agency_estimate_number` and one package that overrides it with a station-specific estimate number. Verify both appear on delivery reconciliation. Call `get_media_buy_delivery` and interpret the measurement window fields: explain why `c3` data may be incomplete immediately after broadcast and when the `c7` window closes.
9. **Multi-agent orchestration and execution** — Manage campaigns across multiple sellers. Trace a cross-publisher suppression scenario: a viewer sees an ad on publisher A, then visits publisher B within the 2-hour recency window — what does Identity Match return and why? Configure frequency parameters (5/week, 2-hour minimum recency) and predict delivery impact. Explain why Context Match and Identity Match are structurally separated. Then design, conceptually and outside the public sales-agent sandbox, an orchestrator-hosted measurement gateway that exposes `get_media_buy_delivery` and `provide_performance_feedback`, grants only those orchestrator tasks, and maps one cross-seller result into seller-local submissions.
10. **Warnings, indicators, and invalidations** — Analyze a success warning and the later resource snapshot; distinguish buy/package/assignment indicator placement, exact type coverage, assignment approval, and webhook invalidation handling.
+11. **Shared-account convergence (3.2 draft)** — Connect to an existing sandbox account that is also managed by a connected platform. Acquire a latest account-change cursor before snapshotting, observe an externally initiated creative or media-buy transition, drain `list_account_changes`, and repair through the named authoritative read. Complete the [shared-account change feed lab](/docs/learning/shared-account-change-feed).
For the indicator exercise, Addie supplies this compact creative-library fixture:
diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx
index 69f679eb2f..7836652296 100644
--- a/docs/media-buy/specification.mdx
+++ b/docs/media-buy/specification.mdx
@@ -220,14 +220,14 @@ Any non-terminal ──── update(canceled: true) ──▶ canceled (termina
- `active` or `paused` → `completed` when the flight ends, goal is met, or budget is exhausted (seller-initiated)
- Buyer-initiated cancellation uses `update_media_buy` with `canceled: true` and optional `cancellation_reason`
- On the `update_media_buy` compatibility facade, root `canceled: true` takes precedence over package cancellation and every other requested mutation. The seller cancels the whole MediaBuy, releases all of its package assignments, ignores every other field except `cancellation_reason`, and SHOULD return a structured warning identifying the ignored fields. A package-only cancellation is evaluated only when root `canceled` is absent. Compact `control_media_buy` instead makes root cancellation mutually exclusive with package and other controls, so a mixed compact request is invalid rather than precedence-resolved.
-- Seller-initiated cancellation (e.g., policy violation, inventory withdrawal) transitions the media buy to `canceled` with `cancellation.canceled_by: "seller"`; buyers recover the state through `get_media_buys`. A durable resource-scoped status webhook is reserved for the 4.0 notification model—operation-scoped `push_notification_config` is not a future lifecycle subscription.
+- Seller-initiated cancellation (e.g., policy violation, inventory withdrawal) transitions the media buy to `canceled` with `cancellation.canceled_by: "seller"`; buyers recover the state through `get_media_buys`. A durable resource-scoped status webhook remains reserved for the 4.0 notification model—operation-scoped `push_notification_config` is not a future lifecycle subscription. A 3.2 seller advertising `account.change_feed` with `media_buy` coverage instead emits the generic account-anchored `account.change_recorded` invalidation, which the buyer repairs through `get_media_buys`; it is not a resource-status payload.
- Seller-initiated rejection (from `pending_creatives` or `pending_start`) is likewise observable through `get_media_buys`; implementations MUST NOT synthesize an operation-completion webhook after the originating operation has already completed.
- Sales agents MUST include a `cancellation` object with `canceled_at` and `canceled_by` when transitioning a media buy or package to `canceled`
- Sales agents MAY reject buyer cancellation of a non-terminal media buy with error code `NOT_CANCELLABLE` (e.g., when the seller contractually refuses mid-flight cancellation)
- When a buyer attempts to cancel a media buy already in `canceled` (`canceled: true` on a `canceled` buy), sales agents MUST reject with `NOT_CANCELLABLE`
- All other updates to media buys in terminal states (`completed`, `rejected`, `canceled`) — including `canceled: true` attempts against `completed` or `rejected` buys — MUST be rejected with `INVALID_STATE`
- Rejection (`rejected` status) is only valid from `pending_creatives` or `pending_start`. Sales agents MUST NOT reject media buys that have already transitioned to `active`.
-- The current 3.x wire does not define a durable seller-initiated MediaBuy status subscription. Sellers MUST preserve the transition in MediaBuy readback and history; buyers that need immediate push delivery should negotiate an implementation extension until the 4.0 resource-scoped notification contract lands.
+- The base 3.x media-buy wire does not define a durable seller-initiated resource-status subscription. Sellers MUST preserve the transition in MediaBuy readback and history. In 3.2, a seller MAY provide immediate generic invalidation by advertising `account.change_feed` with `media_buy` coverage and accepting an `account.change_recorded` account subscriber; otherwise buyers needing immediate push delivery require an implementation extension until the 4.0 resource-scoped notification contract lands.
- The `canceled` field on update requests uses `"const": true` — only `true` is valid. Sending `canceled: false` fails schema validation. Cancellation is irreversible; there is no "uncancel" operation.
- **Creative assignments are released on buy rejection or cancellation.** When a media buy transitions to `rejected` or `canceled`, all package-creative assignments on that media buy are released. For sellers that advertise `creative.has_creative_library: true`, the creatives persist in the creative library per [assignment state and creative state](/docs/creative/creative-libraries#creative-state-and-assignment-state-are-separate) and MAY be referenced by `creative_id` in a subsequent `create_media_buy` or `sync_creatives` call. Inline-only sellers that advertise `inline_creative_management` without a creative library MAY keep submitted creatives package-scoped; they do not advertise cross-buy reuse or `list_creatives` readback.
- **Creative review is independent of buy outcome.** Sales agents MUST NOT implicitly reject a creative because its containing buy was rejected; a creative rejection MUST be a deliberate review decision with its own `rejection_reason`. If the buy was rejected because a creative violated content policy, the sales agent MAY reject that creative — but only via the normal review path with its own `rejection_reason`; the buy's `rejected` status is not itself sufficient.
diff --git a/docs/media-buy/task-reference/control_media_buy.mdx b/docs/media-buy/task-reference/control_media_buy.mdx
index f1bb771a4d..55a5212f5c 100644
--- a/docs/media-buy/task-reference/control_media_buy.mdx
+++ b/docs/media-buy/task-reference/control_media_buy.mdx
@@ -85,4 +85,4 @@ A completed in-envelope control MAY include `warnings[]` for non-blocking observ
`canceled: true` is direct only when the accepted cancellation policy already grants the caller that right. A cancellation requiring counterparty agreement uses `refine_proposals` with `change_kind: "cancellation"`.
-Seller-initiated cancellation does not call a buyer tool. The seller advances the MediaBuy revision and records `cancellation.canceled_by: "seller"`; `get_media_buys` is the normative recovery surface. A durable compact-lifecycle status-change webhook is intentionally not inferred from the per-operation async callback and remains 4.0 work.
+Seller-initiated cancellation does not call a buyer tool. The seller advances the MediaBuy revision and records `cancellation.canceled_by: "seller"`; `get_media_buys` is the normative recovery surface. A durable compact-lifecycle resource-status webhook is intentionally not inferred from the per-operation async callback and remains 4.0 work. Separately, a 3.2 seller advertising `account.change_feed` with `media_buy` coverage emits generic account-anchored `account.change_recorded`, which tells the buyer to drain the feed and repair through `get_media_buys` rather than carrying status itself.
diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx
index 1f7fc7b530..2ae8491492 100644
--- a/docs/protocol/get_adcp_capabilities.mdx
+++ b/docs/protocol/get_adcp_capabilities.mdx
@@ -353,6 +353,7 @@ Account and authentication capabilities. All sellers should declare this section
| `account_financials` | boolean | Default: `false`. When `true`, the seller supports [`get_account_financials`](/docs/accounts/tasks/get_account_financials) for querying spend, credit, and invoice status. Only applicable to operator-billed accounts. |
| `identity_updates` | object | Optional capability gate for reconciling an existing account's buyer-controlled operator identity through `sync_accounts` settings-update mode. `supported_changes` declares `operator_unit_name`, `operator_unit`, and/or `operator`. |
| `notifications` | object | Optional. Declares durable account lifecycle webhook support. When `supported: true`, buyers may register `account.status_changed` subscribers with `sync_accounts.accounts[].notification_configs[]` and repair by re-reading `list_accounts`. |
+| `change_feed` | object | Optional. Declares the durable [`list_account_changes`](/docs/accounts/tasks/list_account_changes) feed and `account.change_recorded` wake-up. When supported, includes a retention floor of at least 90 days and the resource types covered. |
| `sandbox` | boolean | Default: `false`. Strongly recommended for production sales agents. When `true`, the seller supports sandbox accounts for testing. Account-id namespaces discover pre-existing test accounts through `list_accounts` or out-of-band setup. Buyer-declared accounts use `sandbox: true` in `sync_accounts`, or in the natural-key account reference when the seller uses unambiguous lazy provisioning — no real platform calls or spend. See [Sandbox mode](/docs/media-buy/advanced-topics/sandbox). |
#### account.notifications
@@ -382,6 +383,42 @@ Declares whether the seller supports durable account lifecycle invalidation webh
When `supported: false` or absent, buyers MUST NOT assume durable account status webhooks are available. They can still use the one-shot `sync_accounts.push_notification_config` callback for the initial provisioning result when offered, and poll `list_accounts` for later account status changes.
+#### account.change_feed
+
+Declares whether the seller exposes a durable ordered account change feed.
+This capability is separate from `account.notifications`, which covers the
+specialized account lifecycle invalidation, and from `webhook_activity`, which
+is only delivery-attempt diagnostics.
+
+```json
+{
+ "account": {
+ "change_feed": {
+ "supported": true,
+ "read_task": "list_account_changes",
+ "registration_task": "sync_accounts",
+ "event_type": "account.change_recorded",
+ "retention_days": 90,
+ "resource_types": [
+ "account",
+ "account_financials",
+ "media_buy",
+ "package",
+ "creative",
+ "creative_assignment",
+ "delivery_report"
+ ]
+ }
+ }
+}
+```
+
+Advertising a resource type is a completeness commitment: every material
+change to fields recoverable through that resource's authoritative read is
+recorded regardless of origin. Snapshot completeness is normative and is not
+weakened into a self-asserted `complete: true` flag. Account-specific connected
+source status and freshness are returned by `list_account_changes`.
+
See [Provision a seller-mediated account](/docs/accounts/provisioning-walkthrough) for the complete discovery, registration, human setup, webhook, and repair sequence.
#### account.timezone
diff --git a/docs/protocol/snapshot-and-log.mdx b/docs/protocol/snapshot-and-log.mdx
index 6c029be83f..c134b1ea77 100644
--- a/docs/protocol/snapshot-and-log.mdx
+++ b/docs/protocol/snapshot-and-log.mdx
@@ -176,6 +176,7 @@ Resources that outlive a single media buy register their push channel on the acc
- **[#2261](https://github.com/adcontextprotocol/adcp/issues/2261) creative lifecycle, assignment, and indicator webhooks** — `list_creatives.creatives[].webhook_activity[]` adopts this pattern for `creative.status_changed`, `creative.purged`, `creative.assignment_changed`, and assignment-level `indicators.changed`. The notification channel is the account's `notification_configs[]` set, registered via `sync_accounts` in either provisioning or settings-update mode. Supported event types and per-type coalescence windows are declared via [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities). The parent creative is unambiguous, so `ext.creative_id` MAY be omitted on inner records. Assignment and indicator events are invalidations repaired completely through [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys); [`list_creatives`](/docs/creative/task-reference/list_creatives) is an optional bounded reverse projection, and neither its rows nor the webhook payload replaces the authoritative snapshot. See [list_creatives § Webhook activity](/docs/creative/task-reference/list_creatives#webhook-activity) for the call-site documentation.
- **[#5915](https://github.com/adcontextprotocol/adcp/issues/5915) account status webhooks** — `list_accounts.accounts[].webhook_activity[]` uses the same pattern for `account.status_changed`. The webhook invalidates the account snapshot; buyers re-read `list_accounts` for the authoritative status, setup hints, billing terms, and authorization state. The payload intentionally omits `setup.url` so single-use setup links are fetched through the authenticated read path instead of fanned out to every subscriber.
+- **Durable account changes (3.2 draft)** — [`list_account_changes`](/docs/accounts/tasks/list_account_changes) is a separate business-state change feed, while `account.change_recorded` is its generic wake-up. It does not replace specialized resource notifications or `webhook_activity[]`. The feed records material changes regardless of origin and points to authoritative repair reads; the webhook activity surface still records only delivery attempts.
- **Future account-scoped resources** follow the same chain only after defining both halves: subscribe through `sync_accounts.accounts[].notification_configs[]`, name an authoritative repair read, and adopt `webhook_activity[]` on that read when transport observability is supported.
Adopters follow this checklist verbatim regardless of whether the notification channel is per-buy, per-account, or agent-level.
@@ -189,10 +190,10 @@ Adopters follow this checklist verbatim regardless of whether the notification c
## Current limits
-- **Transition history is not a 3.2 replay surface.** Current-state pairs recover the resource as it exists now; event-only reason, prior-state, initiator, and changed-field metadata can be lost when a push is missed.
+- **Full historical payload replay is not a 3.2 surface.** The optional account change feed retains bounded change metadata and repair pointers, not before/after resource documents. Buyers that require regulatory archives still persist authorized snapshots and reporting data themselves.
- **Delivery parity is capability-scoped.** [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery) reproduces reporting data only at declared `windowed_pull_granularities`; buyers must persist higher-frequency webhook data when the frequency is outside that set.
- **Activity identity is migration-safe.** `notification_id` is optional on `webhook_activity[]` records in 3.2 so sellers can return retained pre-adoption records. Strict presence for identity-bearing event types requires a major-version migration contract.
-- **Audience lifecycle remains pull-only.** A fresh [`sync_audiences`](/docs/media-buy/task-reference/sync_audiences) is the reliable signal when an audience is not represented by an active media-buy impairment.
+- **Audience coverage is capability-gated.** A seller cannot advertise audience coverage in the account change feed until discovery exposes stable native/connected identity, management origin, and current revision. Until then a fresh [`sync_audiences`](/docs/media-buy/task-reference/sync_audiences) remains the reliable lifecycle signal.
## When you'd be right to push back
diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx
index a09e182c2e..038c31aee5 100644
--- a/docs/snippets/compliance-error-codes.mdx
+++ b/docs/snippets/compliance-error-codes.mdx
@@ -53,6 +53,7 @@ description: "Canonical AdCP error codes with recovery classifications, remediat
| `CREATIVE_REVISION_CONTENT_MISMATCH` | correctable | resend the exact content previously bound to this revision_id, or mint a new revision_id for changed content |
| `CREATIVE_VALUE_NOT_ALLOWED` | correctable | pick a value from error.details.allowed_values (or re-fetch the format) and resubmit |
| `CREDENTIAL_IN_ARGS` | terminal | do NOT auto-retry — auto-retry re-logs the credential on each attempt. Move authentication material or caller-supplied trust material out of request args (top-level, {"context"}, {"ext"}, any nested location) onto the relevant transport authentication/trust channel or account provisioning path (Authorization: Bearer, RFC 9421 signature/JWKS, mTLS, MCP/A2A authentication framing); rotate any leaked credential, then resubmit |
+| `CURSOR_EXPIRED` | correctable | obtain a latest checkpoint, rebuild authoritative account snapshots, then drain changes after the checkpoint |
| `EVALUATOR_AGENT_NOT_ACCEPTED` | correctable | replace the evaluator agent_url (evaluator.feature_agent.agent_url or the evaluator agent-form agent_url) with one from the seller's published accepted_verifiers, or drop the evaluator agent pointer to fall back to seller-default ranking |
| `FEED_FETCH_FAILED` | correctable | check URL accessibility, authentication, and that content matches the declared feed_format |
| `FIELD_NOT_PERMITTED` | correctable | drop the disallowed field(s) and retry |
@@ -497,6 +498,15 @@ A submitted text-asset value is not in the format's declared {"allowed_val
The seller detected authentication material or caller-supplied trust material placed in request args (top-level, in {"context"}, in {"ext"}, or any other nested location in the task payload) instead of arriving on the relevant transport authentication or trust channel. This includes buyer-principal credentials that should arrive on the inbound transport ({"Authorization: Bearer"} per RFC 6750 §2 for HTTP, RFC 9421 signature headers for signed requests, MCP/A2A authentication framing per RFC 9728 §3), and evaluator-call credentials or JWK/JWKS/JWKS-URI trust material smuggled into evaluator-related payload fields instead of being established through the creative agent's outbound transport authentication to the evaluator. Distinct from {"AUTH_REQUIRED"} (no credentials presented or presented credentials rejected on the transport channel) and {"PERMISSION_DENIED"} (authenticated caller not authorized for the action). Distinct from the receiver-side credentials carried in {"push_notification_config.authentication.credentials"}, which configure the seller's webhook callback authentication and are not buyer-principal or evaluator-call credentials — those are an explicit carve-out and MUST NOT trigger this code. Sellers SHOULD reject credential-in-args under AdCP 3.1; the requirement upgrades to MUST 90 days after the 3.1 publication date. Recovery: terminal — the agent MUST NOT auto-retry. Auto-retry against this code re-logs the credential on each attempt across the seller's request logs, observability stack, and any LLM-context surfaces in the buyer-side recovery loop, exactly the prompt-injection exfiltration surface that motivated the rule. Wire placement. Sellers MUST flip transport-level failure markers (HTTP 4xx, MCP {"isError: true"}, A2A {"failed"}) and populate both layers per the two-layer model in {"error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model"}. The code itself is the discriminator; no {"error.details"} shape is defined, and {"error.field"} MUST NOT echo the offending credential value or any prefix of it (e.g., {"\"Bearer ey...\""}). {"error.message"} MUST be generic and MUST NOT contain credential material. Sellers MUST drop the smuggled credential from logs, audit rows, and observability spans before persisting the rejection — the rejection itself is otherwise an exfiltration surface.
+
+
+
+
+
+**Suggested action:** obtain a latest checkpoint, rebuild authoritative account snapshots, then drain changes after the checkpoint
+
+The list_account_changes cursor is no longer within the seller's retained account change window. The seller MUST NOT silently restart from the retention boundary. Recovery: correctable (obtain a new starting_position: latest checkpoint, rebuild every authoritative account snapshot, then drain changes after that checkpoint). error.details SHOULD include available_since and MAY include a replacement starting-position hint, without disclosing inaccessible history.
+
diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json
index 64a156e42d..a31fc8c8d7 100644
--- a/scripts/error-code-drift-dispositions.json
+++ b/scripts/error-code-drift-dispositions.json
@@ -36,6 +36,11 @@
"target_version": "3.2",
"note": "Canonical 3.2 PackageRequest migration guard. Returned when multiple resolvable format selector routes select different product format contracts. New wire code — held for 3.2."
},
+ "CURSOR_EXPIRED": {
+ "disposition": "held-for-next-minor",
+ "target_version": "3.2",
+ "note": "Account change feeds return this when a durable checkpoint falls outside retained history. New 3.2 account wire code — held for 3.2."
+ },
"BIDDING_PLACEMENT_CONFLICT": {
"disposition": "held-for-next-minor",
"target_version": "3.2",
diff --git a/server/src/addie/mcp/certification-tools.ts b/server/src/addie/mcp/certification-tools.ts
index 38db3ab1d6..1c637f17c9 100644
--- a/server/src/addie/mcp/certification-tools.ts
+++ b/server/src/addie/mcp/certification-tools.ts
@@ -1442,6 +1442,8 @@ export const MODULE_RESOURCES: Record
],
D3: [
{ label: 'Platform track overview', url: `${DOCS_BASE}/docs/learning/tracks/platform` },
+ { label: 'Shared-account change feed lab', url: `${DOCS_BASE}/docs/learning/shared-account-change-feed` },
+ { label: 'Snapshot and log contract', url: `${DOCS_BASE}/docs/protocol/snapshot-and-log` },
{ label: 'How AdCP compares to OpenRTB', url: `${DOCS_BASE}/docs/building/concepts/adcp-vs-openrtb` },
{ label: 'Trusted Match Protocol', url: `${DOCS_BASE}/docs/trusted-match` },
{ label: 'TMP specification', url: `${DOCS_BASE}/docs/trusted-match/specification` },
@@ -1461,6 +1463,8 @@ export const MODULE_RESOURCES: Record
// Track S: Specialist deep dives
S1: [
{ label: 'Media buy protocol', url: `${DOCS_BASE}/docs/media-buy` },
+ { label: 'Shared-account change feed lab', url: `${DOCS_BASE}/docs/learning/shared-account-change-feed` },
+ { label: 'List account changes task', url: `${DOCS_BASE}/docs/accounts/tasks/list_account_changes` },
{ label: 'Proposal negotiation with refine_proposals', url: `${DOCS_BASE}/docs/media-buy/task-reference/refine_proposals` },
{ label: 'Proposal refinement capabilities', url: `${DOCS_BASE}/docs/protocol/get_adcp_capabilities#proposal-refinement` },
{ label: 'Create media buy task', url: `${DOCS_BASE}/docs/media-buy/task-reference/create_media_buy` },
diff --git a/server/src/training-agent/account-handlers.ts b/server/src/training-agent/account-handlers.ts
index 12fc44004d..6a0e299749 100644
--- a/server/src/training-agent/account-handlers.ts
+++ b/server/src/training-agent/account-handlers.ts
@@ -5,7 +5,8 @@
* Accounts are stored in session state; governance agents are stored per-account.
*/
-import { randomUUID } from 'node:crypto';
+import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
+import type { WebhookAuthentication } from '@adcp/sdk/server';
import type { TrainingContext, ToolArgs, AccountRef, OperatorUnit } from './types.js';
import { accountScopeFromRef } from './account-scope.js';
import { sessionKeyFromArgs } from './state.js';
@@ -23,6 +24,8 @@ import {
normalizeAccountWebhookUrl,
proveAccountWebhookControl,
} from './webhook-challenge.js';
+import { emitAccountNotificationWebhook } from './webhooks.js';
+import { clearSharedAccountResources } from './shared-account-resources.js';
// One account may legitimately use the protocol's full 16-subscriber fan-out.
// Larger multi-account activations must be split by account so a single call
@@ -51,6 +54,8 @@ interface SyncAccountInput {
}
interface AccountState {
+ /** Internal seller scope. Equal wire IDs do not imply shared access. */
+ changeScopeId: string;
accountId: string;
brand: { domain: string; brand_id?: string; countries?: string[]; name?: string };
operator: string;
@@ -70,6 +75,47 @@ interface AccountState {
syncedAt: string;
}
+export interface TrainingAccountChange {
+ change_id: string;
+ recorded_at: string;
+ occurred_at?: string;
+ batch_id?: string;
+ resource: {
+ type: string;
+ account_id: string;
+ resource_id: string;
+ parent_ids?: Record;
+ };
+ action: string;
+ origin: {
+ kind: 'adcp' | 'seller_operator' | 'seller_system' | 'connected_platform' | 'unknown';
+ connection_id?: string;
+ };
+ resource_revision?: number | string;
+ changed_paths?: string[];
+ repair: {
+ task: string;
+ available?: boolean;
+ unavailable_reason?: string;
+ };
+ reason?: string;
+ summary?: string;
+}
+
+interface StoredAccountChange {
+ sequence: number;
+ change: TrainingAccountChange;
+}
+
+interface AccountChangeCursor {
+ principal: string;
+ accountScopeId: string;
+ visibilityEpoch: string;
+ accountId: string;
+ resourceTypes: string[];
+ sequence: number;
+}
+
export interface GovernanceAgentEntry {
url: string;
}
@@ -117,6 +163,107 @@ interface GovernanceAgentInput {
// module-level Map keyed by session key → account key → AccountState.
// This avoids modifying the shared SessionState interface.
const accountStore = new Map>();
+const accountChangeStore = new Map();
+const accountChangeNextSequence = new Map();
+const accountChangeAvailableFrom = new Map();
+const accountChangeVisibilityEpochs = new Map();
+const ACCOUNT_CHANGE_CURSOR_SECRET = randomUUID();
+const ACCOUNT_CHANGE_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
+
+function principalAccountChangeScope(principal: string | undefined, accountId: string): string {
+ return `principal:${principalScope(principal)}:${accountId}`;
+}
+
+function accountChanges(accountScopeId: string): StoredAccountChange[] {
+ const key = accountScopeId;
+ let changes = accountChangeStore.get(key);
+ if (!changes) {
+ changes = [];
+ accountChangeStore.set(key, changes);
+ accountChangeNextSequence.set(key, 0);
+ accountChangeAvailableFrom.set(key, Date.now());
+ }
+ return changes;
+}
+
+function pruneExpiredAccountChanges(accountScopeId: string, nowMs: number): void {
+ const changes = accountChanges(accountScopeId);
+ const cutoff = nowMs - ACCOUNT_CHANGE_RETENTION_MS;
+ let expiredCount = 0;
+ while (expiredCount < changes.length) {
+ const recordedAt = Date.parse(changes[expiredCount].change.recorded_at);
+ if (!Number.isFinite(recordedAt) || recordedAt >= cutoff) break;
+ expiredCount += 1;
+ }
+ if (expiredCount > 0) changes.splice(0, expiredCount);
+}
+
+function accountChangeAvailableSince(accountScopeId: string, nowMs: number): string {
+ const adoptedAt = accountChangeAvailableFrom.get(accountScopeId) ?? nowMs;
+ return new Date(Math.max(adoptedAt, nowMs - ACCOUNT_CHANGE_RETENTION_MS)).toISOString();
+}
+
+export function recordAccountChange(
+ principal: string | undefined,
+ input: Omit & {
+ change_id?: string;
+ recorded_at?: string;
+ },
+): TrainingAccountChange {
+ const change: TrainingAccountChange = {
+ change_id: input.change_id ?? `chg_${randomUUID()}`,
+ recorded_at: input.recorded_at ?? new Date().toISOString(),
+ ...(input.occurred_at && { occurred_at: input.occurred_at }),
+ ...(input.batch_id && { batch_id: input.batch_id }),
+ resource: {
+ ...input.resource,
+ ...(input.resource.parent_ids && { parent_ids: { ...input.resource.parent_ids } }),
+ },
+ action: input.action,
+ origin: { ...input.origin },
+ ...(input.resource_revision !== undefined && { resource_revision: input.resource_revision }),
+ ...(input.changed_paths && { changed_paths: [...input.changed_paths] }),
+ repair: {
+ ...input.repair,
+ },
+ ...(input.reason && { reason: input.reason }),
+ ...(input.summary && { summary: input.summary }),
+ };
+ const ownedAccount = findAccountByIdAcrossSessions(change.resource.account_id, principal);
+ const accountScopeId = ownedAccount?.changeScopeId
+ ?? (getComplianceAccounts().some(account => account.account_id === change.resource.account_id)
+ ? `fixture:${change.resource.account_id}`
+ : principalAccountChangeScope(principal, change.resource.account_id));
+ const changes = accountChanges(accountScopeId);
+ const sequence = (accountChangeNextSequence.get(accountScopeId) ?? 0) + 1;
+ accountChangeNextSequence.set(accountScopeId, sequence);
+ changes.push({ sequence, change });
+ return change;
+}
+
+function ensureExistingAccountChangeSeed(accountScopeId: string, principal: string | undefined, account: AccountWireShape): void {
+ accountChanges(accountScopeId);
+ // Seed only when the feed is first adopted for this fixture. An empty
+ // retained array after pruning is not permission to fabricate new history.
+ if ((accountChangeNextSequence.get(accountScopeId) ?? 0) > 0) return;
+ recordAccountChange(principal, {
+ resource: {
+ type: 'account',
+ account_id: account.account_id,
+ resource_id: account.account_id,
+ },
+ action: 'updated',
+ origin: {
+ kind: 'connected_platform',
+ connection_id: 'conn_shared_training_platform',
+ },
+ changed_paths: ['/status'],
+ repair: {
+ task: 'list_accounts',
+ },
+ summary: 'Existing shared training account observed from a connected platform.',
+ });
+}
function principalScope(principal: string | undefined): string {
return principal && principal.length > 0 ? principal : 'anonymous';
@@ -241,6 +388,7 @@ function accountMapsForPrincipal(sessionKey: string, principal?: string): Map();
+ for (const [storeKey, accounts] of accountStore) {
+ for (const account of accounts.values()) {
+ // The stored scope is the seller-issued access grant. A matching wire
+ // account_id alone never authorizes cross-principal fan-out.
+ if (account.changeScopeId !== accountScopeId) continue;
+ for (const config of account.notificationConfigs) {
+ if (!config.active || !config.eventTypes.includes('account.change_recorded')) continue;
+ const subscriberKey = `${storeKey}\u001F${config.subscriberId}`;
+ if (seen.has(subscriberKey)) continue;
+ seen.add(subscriberKey);
+ out.push({
+ accountId: account.accountId,
+ subscriberId: config.subscriberId,
+ url: config.url,
+ eventTypes: [...config.eventTypes],
+ authentication: config.authentication,
+ });
+ }
+ }
+ }
+ return out;
+}
+
+function accountChangeWebhookAuthentication(
+ auth: NotificationConfigState['authentication'] | undefined,
+): WebhookAuthentication | undefined {
+ if (!auth?.credentials) return undefined;
+ const schemes = auth.schemes.map(scheme => scheme.toLowerCase().replace(/-/g, '_'));
+ if (schemes.includes('bearer')) return { type: 'bearer', token: auth.credentials };
+ if (schemes.includes('hmac_sha256')) return { type: 'hmac_sha256', secret: auth.credentials };
+ return undefined;
+}
+
+export async function emitAccountChangeRecordedWebhook(
+ principal: string | undefined,
+ change: TrainingAccountChange,
+): Promise {
+ const ownedAccount = findAccountByIdAcrossSessions(change.resource.account_id, principal);
+ const accountScopeId = ownedAccount?.changeScopeId
+ ?? (getComplianceAccounts().some(account => account.account_id === change.resource.account_id)
+ ? `fixture:${change.resource.account_id}`
+ : principalAccountChangeScope(principal, change.resource.account_id));
+ const subscribers = getAccountChangeSubscribersAcrossPrincipals(accountScopeId);
+ await Promise.allSettled(subscribers.map(async subscriber => {
+ const idempotencyKey = randomUUID();
+ const payload: Record = {
+ idempotency_key: idempotencyKey,
+ notification_id: change.change_id,
+ notification_type: 'account.change_recorded',
+ fired_at: new Date().toISOString(),
+ subscriber_id: subscriber.subscriberId,
+ account_id: change.resource.account_id,
+ change_id: change.change_id,
+ recorded_at: change.recorded_at,
+ resource: {
+ type: change.resource.type,
+ resource_id: change.resource.resource_id,
+ ...(change.resource.parent_ids && { parent_ids: change.resource.parent_ids }),
+ },
+ action: change.action,
+ };
+ await emitAccountNotificationWebhook({
+ url: subscriber.url,
+ payload,
+ operationId: `${subscriber.accountId}:${subscriber.subscriberId}:${change.change_id}:${idempotencyKey}`,
+ notificationType: 'account.change_recorded',
+ authentication: accountChangeWebhookAuthentication(subscriber.authentication),
+ });
+ }));
+}
+
export function resolveAccountIdForRef(
sessionKey: string,
principal: string | undefined,
@@ -605,6 +841,7 @@ export function resolveAccountIdForRef(
}
return ref.account_id
? findAccountByIdAcrossSessions(ref.account_id, principal)?.accountId
+ ?? getComplianceAccounts().find(account => account.account_id === ref.account_id)?.account_id
: undefined;
}
@@ -692,6 +929,7 @@ export function seedAccountFixture(
?? findAccountByIdAcrossSessions(accountId, ctx.principal);
const state: AccountState = {
+ changeScopeId: principalAccountChangeScope(ctx.principal, accountId),
accountId,
brand: brand as { domain: string; brand_id?: string; countries?: string[]; name?: string },
operator,
@@ -731,6 +969,17 @@ export function seedAccountFixture(
// storyboards that rely on stable account IDs work without prior sync_accounts.
function getComplianceAccounts(): AccountWireShape[] {
return [
+ {
+ account_id: 'acc_luma_shared',
+ name: 'Luma Outdoor — shared connected-platform sandbox',
+ advertiser: 'Luma Outdoor',
+ brand: { domain: 'luma-outdoor.example' },
+ operator: 'pinnacle-agency.example',
+ billing: 'operator',
+ account_scope: 'operator_brand',
+ status: 'active',
+ sandbox: true,
+ },
{
account_id: 'acc_pagination_integrity_1',
name: 'Acme Outdoor c/o Pinnacle',
@@ -1105,6 +1354,17 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
const nextNotificationConfigs = notificationConfigsProvided
? notificationConfigs
: existing.notificationConfigs;
+ const changedPaths: string[] = [];
+ if (input.payment_terms && input.payment_terms !== existing.paymentTerms) {
+ changedPaths.push('/payment_terms');
+ }
+ if (
+ notificationConfigsProvided
+ && JSON.stringify(sanitizeNotificationConfigs(notificationConfigs))
+ !== JSON.stringify(sanitizeNotificationConfigs(existing.notificationConfigs))
+ ) {
+ changedPaths.push('/notification_configs');
+ }
const result: Record = {
account_id: existing.accountId,
@@ -1132,6 +1392,21 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
existing.notificationConfigsTouched = true;
}
existing.syncedAt = now;
+ if (changedPaths.length > 0) {
+ const change = recordAccountChange(ctx.principal, {
+ resource: {
+ type: 'account',
+ account_id: existing.accountId,
+ resource_id: existing.accountId,
+ },
+ action: 'updated',
+ origin: { kind: 'adcp' },
+ changed_paths: changedPaths,
+ repair: { task: 'list_accounts' },
+ summary: 'Account settings changed through sync_accounts.',
+ });
+ await emitAccountChangeRecordedWebhook(ctx.principal, change);
+ }
results.push(result);
continue;
}
@@ -1305,7 +1580,10 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
// Sandbox accounts are active immediately; non-sandbox may need approval
const status = isSandbox ? 'active' : (existing?.status === 'active' ? 'active' : 'pending_approval');
+ const previousWire = existing ? JSON.stringify(accountStateToWire(existing)) : undefined;
const state: AccountState = {
+ changeScopeId: existing?.changeScopeId
+ ?? principalAccountChangeScope(ctx.principal, accountId),
accountId,
brand: input.brand,
operator: input.operator,
@@ -1328,8 +1606,39 @@ export async function handleSyncAccounts(args: ToolArgs, ctx: TrainingContext) {
: existing?.notificationConfigsTouched,
syncedAt: now,
};
+ const provisioningChangedPaths = existing
+ ? [
+ ...(existing.billing !== state.billing ? ['/billing'] : []),
+ ...(existing.paymentTerms !== state.paymentTerms ? ['/payment_terms'] : []),
+ ...(existing.status !== state.status ? ['/status'] : []),
+ ...(
+ JSON.stringify(sanitizeNotificationConfigs(existing.notificationConfigs))
+ !== JSON.stringify(sanitizeNotificationConfigs(state.notificationConfigs))
+ ? ['/notification_configs']
+ : []
+ ),
+ ]
+ : undefined;
accounts.set(key, state);
+ const currentWire = JSON.stringify(accountStateToWire(state));
+ if (!existing || previousWire !== currentWire) {
+ const change = recordAccountChange(ctx.principal, {
+ resource: {
+ type: 'account',
+ account_id: accountId,
+ resource_id: accountId,
+ },
+ action: existing ? 'updated' : 'created',
+ origin: { kind: 'adcp' },
+ changed_paths: provisioningChangedPaths,
+ repair: { task: 'list_accounts' },
+ summary: existing
+ ? 'Account configuration changed through sync_accounts.'
+ : 'Account created through sync_accounts.',
+ });
+ await emitAccountChangeRecordedWebhook(ctx.principal, change);
+ }
const result: Record = {
account_id: accountId,
@@ -1390,6 +1699,14 @@ interface ListAccountsRequest extends ToolArgs {
pagination?: { max_results?: number; cursor?: string };
}
+interface ListAccountChangesRequest extends ToolArgs {
+ account: AccountRef;
+ cursor?: string;
+ starting_position?: 'earliest' | 'latest';
+ resource_types?: string[];
+ max_results?: number;
+}
+
function wireAccountMatchesRef(account: AccountWireShape, ref: AccountRef): boolean {
if (ref.account_id) return account.account_id === ref.account_id;
if (!ref.brand?.domain || !ref.operator) return false;
@@ -1428,6 +1745,9 @@ export function handleListAccounts(args: ToolArgs, ctx: TrainingContext): object
const sessionKey = sessionKeyFromArgs({}, ctx.mode, ctx.userId, ctx.moduleId);
const accountMap = getAccountMap(sessionKey, ctx.principal);
const preferFixtureAccounts = ctx.storyboardCompat?.version === '3.0';
+ const complianceAccounts = preferFixtureAccounts
+ ? getComplianceAccounts().filter(account => account.account_id !== 'acc_luma_shared')
+ : getComplianceAccounts();
const exactAccountFilter = hasExactAccountFilter(req.account)
&& (req.sandbox !== true || Boolean(req.account?.account_id));
const scopedAccounts = accountsForPrincipal(ctx.principal);
@@ -1435,15 +1755,15 @@ export function handleListAccounts(args: ToolArgs, ctx: TrainingContext): object
let accounts: AccountWireShape[] = preferFixtureAccounts
? scopedAccounts.length > 0
? scopedAccounts.map(accountStateToWire)
- : getComplianceAccounts()
+ : complianceAccounts
: scopedAccounts.length > 0
? scopedAccounts.map(accountStateToWire)
: accountMap.size > 0
? Array.from(accountMap.values()).map(accountStateToWire)
- : getComplianceAccounts();
+ : complianceAccounts;
if (!preferFixtureAccounts && req.sandbox === true && !exactAccountFilter) {
- accounts = mergeAccountFixtures(accounts, getComplianceAccounts());
+ accounts = mergeAccountFixtures(accounts, complianceAccounts);
}
if (!preferFixtureAccounts && exactAccountFilter) {
accounts = accounts.filter(a => wireAccountMatchesRef(a, req.account!));
@@ -1478,6 +1798,247 @@ export function handleListAccounts(args: ToolArgs, ctx: TrainingContext): object
};
}
+function resolveAccountForChangeFeed(
+ ref: AccountRef,
+ principal?: string,
+): { account: AccountWireShape; scopeId: string } | undefined {
+ const scoped = accountsForPrincipal(principal)
+ .find(account => wireAccountMatchesRef(accountStateToWire(account), ref));
+ if (scoped) return { account: accountStateToWire(scoped), scopeId: scoped.changeScopeId };
+ const fixture = getComplianceAccounts().find(account => wireAccountMatchesRef(account, ref));
+ return fixture ? { account: fixture, scopeId: `fixture:${fixture.account_id}` } : undefined;
+}
+
+function normalizeResourceTypes(types: string[] | undefined): string[] {
+ return [...new Set(types ?? [])].sort();
+}
+
+function sameStringArray(left: string[], right: string[]): boolean {
+ return left.length === right.length && left.every((value, index) => value === right[index]);
+}
+
+function accountChangeVisibilityEpochKey(principal: string | undefined, accountScopeId: string): string {
+ return `${principalScope(principal)}\u001F${accountScopeId}`;
+}
+
+function accountChangeVisibilityEpoch(principal: string | undefined, accountScopeId: string): string {
+ // This reference seller grants immutable full-account visibility for the
+ // lifetime of one internal access scope. Sellers with mutable partial
+ // visibility must rotate this value whenever that visible set changes.
+ const epochKey = accountChangeVisibilityEpochKey(principal, accountScopeId);
+ return `full-account:${accountScopeId}:${accountChangeVisibilityEpochs.get(epochKey) ?? 0}`;
+}
+
+/** Sandbox conformance hook: rotate the caller's authorization-scope epoch so
+ * every previously issued cursor for this account expires. This models a
+ * visibility-set change without revoking the controller's ability to finish
+ * the recovery exercise. Ordinary seller code rotates the same epoch when its
+ * real authorization projection changes. */
+export function expireAccountChangeCursors(
+ principal: string | undefined,
+ accountRef: AccountRef,
+): { accountId: string; visibilityEpoch: string } | undefined {
+ const access = resolveAccountForChangeFeed(accountRef, principal);
+ if (!access) return undefined;
+ const epochKey = accountChangeVisibilityEpochKey(principal, access.scopeId);
+ const next = (accountChangeVisibilityEpochs.get(epochKey) ?? 0) + 1;
+ accountChangeVisibilityEpochs.set(epochKey, next);
+ return {
+ accountId: access.account.account_id,
+ visibilityEpoch: accountChangeVisibilityEpoch(principal, access.scopeId),
+ };
+}
+
+function issueAccountChangeCursor(cursor: AccountChangeCursor): string {
+ const payload = Buffer.from(JSON.stringify(cursor)).toString('base64url');
+ const signature = createHmac('sha256', ACCOUNT_CHANGE_CURSOR_SECRET)
+ .update(payload)
+ .digest('base64url');
+ return `accchg_${payload}.${signature}`;
+}
+
+function readAccountChangeCursor(token: string): AccountChangeCursor | undefined {
+ if (!token.startsWith('accchg_')) return undefined;
+ const [payload, suppliedSignature] = token.slice('accchg_'.length).split('.');
+ if (!payload || !suppliedSignature) return undefined;
+ const expectedSignature = createHmac('sha256', ACCOUNT_CHANGE_CURSOR_SECRET)
+ .update(payload)
+ .digest('base64url');
+ const supplied = Buffer.from(suppliedSignature);
+ const expected = Buffer.from(expectedSignature);
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) return undefined;
+ try {
+ const value = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial;
+ if (
+ typeof value.principal !== 'string'
+ || typeof value.accountScopeId !== 'string'
+ || typeof value.visibilityEpoch !== 'string'
+ || typeof value.accountId !== 'string'
+ || !Array.isArray(value.resourceTypes)
+ || !value.resourceTypes.every(item => typeof item === 'string')
+ || !Number.isSafeInteger(value.sequence)
+ || (value.sequence ?? -1) < 0
+ ) return undefined;
+ return value as AccountChangeCursor;
+ } catch {
+ return undefined;
+ }
+}
+
+function accountChangeFailure(error: Record): object {
+ return {
+ status: 'failed',
+ adcp_error: error,
+ errors: [error],
+ };
+}
+
+export function handleListAccountChanges(args: ToolArgs, ctx: TrainingContext): object {
+ const req = args as unknown as ListAccountChangesRequest;
+ const identityError = durableAccountIdentityError(ctx);
+ if (identityError) {
+ return accountChangeFailure(identityError.errors[0] as unknown as Record);
+ }
+ if (!req.account) {
+ return accountChangeFailure({ code: 'INVALID_REQUEST', message: 'account is required', field: 'account', recovery: 'correctable' });
+ }
+ if (req.cursor && req.starting_position) {
+ return accountChangeFailure({ code: 'INVALID_REQUEST', message: 'cursor and starting_position are mutually exclusive', field: 'cursor', recovery: 'correctable' });
+ }
+
+ const access = resolveAccountForChangeFeed(req.account, ctx.principal);
+ if (!access) {
+ return accountChangeFailure({ code: 'ACCOUNT_NOT_FOUND', message: 'Account is not visible to the authenticated principal', recovery: 'terminal' });
+ }
+ const { account, scopeId: accountScopeId } = access;
+ ensureExistingAccountChangeSeed(accountScopeId, ctx.principal, account);
+
+ const resourceTypes = normalizeResourceTypes(req.resource_types);
+ const visibilityEpoch = accountChangeVisibilityEpoch(ctx.principal, accountScopeId);
+ const changes = accountChanges(accountScopeId);
+ const nowMs = Date.now();
+ pruneExpiredAccountChanges(accountScopeId, nowMs);
+ const currentHighWater = accountChangeNextSequence.get(accountScopeId) ?? 0;
+ const retainedFloor = changes[0]?.sequence !== undefined
+ ? changes[0].sequence - 1
+ : currentHighWater;
+ let afterSequence = req.starting_position === 'latest' ? currentHighWater : 0;
+
+ if (req.cursor) {
+ const cursor = readAccountChangeCursor(req.cursor);
+ if (!cursor) {
+ return accountChangeFailure({
+ code: 'CURSOR_EXPIRED',
+ message: 'The account change cursor is no longer available; rebuild authoritative snapshots from a new latest checkpoint',
+ recovery: 'correctable',
+ details: {
+ available_since: accountChangeAvailableSince(accountScopeId, nowMs),
+ restart_with: { starting_position: 'latest' },
+ },
+ });
+ }
+ if (
+ cursor.principal !== principalScope(ctx.principal)
+ || cursor.accountScopeId !== accountScopeId
+ || cursor.accountId !== account.account_id
+ || !sameStringArray(cursor.resourceTypes, resourceTypes)
+ ) {
+ return accountChangeFailure({
+ code: 'INVALID_REQUEST',
+ message: 'cursor is scoped to a different principal, account, or resource_types filter',
+ field: 'cursor',
+ recovery: 'correctable',
+ });
+ }
+ if (cursor.visibilityEpoch !== visibilityEpoch) {
+ return accountChangeFailure({
+ code: 'CURSOR_EXPIRED',
+ message: 'The authorization scope for this account changed; rebuild authoritative snapshots from a new latest checkpoint',
+ field: 'cursor',
+ recovery: 'correctable',
+ details: {
+ reason: 'authorization_scope_changed',
+ available_since: accountChangeAvailableSince(accountScopeId, nowMs),
+ restart_with: { starting_position: 'latest' },
+ },
+ });
+ }
+ if (cursor.sequence < retainedFloor) {
+ return accountChangeFailure({
+ code: 'CURSOR_EXPIRED',
+ message: 'The account change cursor predates the retained change window; rebuild authoritative snapshots from a new latest checkpoint',
+ recovery: 'correctable',
+ details: {
+ available_since: accountChangeAvailableSince(accountScopeId, nowMs),
+ restart_with: { starting_position: 'latest' },
+ },
+ });
+ }
+ if (cursor.sequence > currentHighWater) {
+ return accountChangeFailure({
+ code: 'INVALID_REQUEST',
+ message: 'cursor checkpoint is ahead of the current account change high-water',
+ field: 'cursor',
+ recovery: 'correctable',
+ });
+ }
+ afterSequence = cursor.sequence;
+ }
+
+ const maxResults = Math.min(Math.max(req.max_results ?? 50, 1), 100);
+ const filter = new Set(resourceTypes);
+ const matches = (entry: StoredAccountChange) => filter.size === 0 || filter.has(entry.change.resource.type);
+ const page: StoredAccountChange[] = [];
+ let scannedSequence = afterSequence;
+ for (const entry of changes) {
+ if (entry.sequence <= afterSequence) continue;
+ scannedSequence = entry.sequence;
+ if (matches(entry)) page.push(entry);
+ if (page.length === maxResults) break;
+ }
+ if (page.length < maxResults) scannedSequence = currentHighWater;
+ const hasMore = changes.some(entry => entry.sequence > scannedSequence && matches(entry));
+ // A terminal filtered page is caught up to seller ingestion, even when the
+ // final records were nonmatching. Persist the true account high-water so a
+ // later poll never rescans changes this page already classified.
+ if (!hasMore) scannedSequence = currentHighWater;
+ const cursor = issueAccountChangeCursor({
+ principal: principalScope(ctx.principal),
+ accountScopeId,
+ visibilityEpoch,
+ accountId: account.account_id,
+ resourceTypes,
+ sequence: scannedSequence,
+ });
+
+ return {
+ status: 'completed',
+ changes: page.map(entry => entry.change),
+ cursor,
+ has_more: hasMore,
+ available_since: accountChangeAvailableSince(accountScopeId, nowMs),
+ generated_at: new Date(nowMs).toISOString(),
+ source_coverage: [
+ {
+ source_id: 'seller',
+ kind: 'seller',
+ status: 'current',
+ resource_types: ['creative'],
+ },
+ ...(accountScopeId.startsWith('fixture:') ? [{
+ source_id: 'conn_shared_training_platform',
+ kind: 'connected_platform',
+ status: 'current',
+ coverage_start: accountChangeAvailableSince(accountScopeId, nowMs),
+ observed_through: new Date(nowMs).toISOString(),
+ last_successful_sync_at: new Date(nowMs).toISOString(),
+ stale_after_seconds: 300,
+ resource_types: ['creative'],
+ }] : []),
+ ],
+ };
+}
+
export function handleSyncGovernance(args: ToolArgs, ctx: TrainingContext) {
const req = args as unknown as SyncGovernanceInput;
const identityError = durableAccountIdentityError(ctx);
diff --git a/server/src/training-agent/comply-test-controller.ts b/server/src/training-agent/comply-test-controller.ts
index 42f554aaeb..eefba63824 100644
--- a/server/src/training-agent/comply-test-controller.ts
+++ b/server/src/training-agent/comply-test-controller.ts
@@ -35,7 +35,7 @@ import type {
ComplyBudgetSimulation,
SeededProductAvailability,
} from './types.js';
-import { supportsGetProductsRejected } from './types.js';
+import { supportsAccountChangeFeed, supportsGetProductsRejected } from './types.js';
import {
findSessionsMatching,
findSessionMatching,
@@ -46,13 +46,26 @@ import {
import { getAgentUrl } from './config.js';
import { randomUUID } from 'node:crypto';
import {
+ emitAccountChangeRecordedWebhook,
+ expireAccountChangeCursors,
getAccountNotificationSubscribers,
+ recordAccountChange,
+ resolveAccountIdForRef,
sandboxAccountRefForId,
seedAccountFixture,
} from './account-handlers.js';
import { canonicalizeAccountRef, type CanonicalAccountRef } from './account-scope.js';
-import { verifyGovernanceToken, mintRevokedDemoToken, mintWrongAudDemoToken } from './governance-verify.js';
+import {
+ verifyGovernanceToken as inspectGovernanceTokenForTraining,
+ mintRevokedDemoToken,
+ mintWrongAudDemoToken,
+} from './governance-verify.js';
import { emitAccountNotificationWebhook } from './webhooks.js';
+import {
+ getSharedAccountCreative,
+ removeSharedAccountCreative,
+ upsertSharedAccountCreative,
+} from './shared-account-resources.js';
import { buildCatalog } from './product-factory.js';
import { getAllSignals } from './signal-providers.js';
import {
@@ -802,6 +815,8 @@ function createStore(
principal?: string,
storyboardCompat?: TrainingContext['storyboardCompat'],
controllerAccount?: NaturalAccountIdentity,
+ controllerAccountRef?: AccountRef,
+ controllerAccountId?: string,
): TestControllerStore {
return {
async forceAudienceStatus(audienceId, status, reason) {
@@ -860,7 +875,8 @@ function createStore(
},
async forceCreativeStatus(creativeId, status, rejectionReason) {
- const creative = session.creatives.get(creativeId);
+ const creative = session.creatives.get(creativeId)
+ ?? getSharedAccountCreative(controllerAccountId, creativeId);
if (!creative) {
const priorTerminalState = session.complyExtensions.forcedCreativeTerminalStates.get(creativeId);
if (priorTerminalState) {
@@ -888,6 +904,24 @@ function createStore(
session.complyExtensions.forcedCreativeTerminalStates.delete(creativeId);
}
propagateCreativeImpairment(session, creativeId, prev, status, rejectionReason);
+ const accountId = creative.accountId
+ ?? resolveAccountIdForRef(sessionKey, principal, creative.accountRef);
+ if (accountId) {
+ const change = recordAccountChange(principal, {
+ resource: {
+ type: 'creative',
+ account_id: accountId,
+ resource_id: creativeId,
+ },
+ action: 'status_changed',
+ origin: { kind: 'connected_platform', connection_id: 'conn_shared_training_platform' },
+ changed_paths: ['/status'],
+ repair: { task: 'list_creatives' },
+ reason: lifecycleReasonCode(prev, status),
+ summary: `Connected training platform changed creative status from ${prev} to ${status}.`,
+ });
+ await emitAccountChangeRecordedWebhook(principal, change);
+ }
await emitCreativeStatusChanged(sessionKey, principal, creative, prev, status, rejectionReason);
return { success: true, previous_state: prev, current_state: status, message: `Creative ${creativeId} transitioned from ${prev} to ${status}` };
},
@@ -952,6 +986,24 @@ function createStore(
summary: `Comply test controller forced status to ${status}`,
});
+ const accountId = resolveAccountIdForRef(sessionKey, principal, mb.accountRef);
+ if (accountId) {
+ const change = recordAccountChange(principal, {
+ resource: {
+ type: 'media_buy',
+ account_id: accountId,
+ resource_id: mediaBuyId,
+ },
+ action: 'status_changed',
+ origin: { kind: 'connected_platform', connection_id: 'conn_shared_training_platform' },
+ resource_revision: mb.revision,
+ changed_paths: ['/status'],
+ repair: { task: 'get_media_buys' },
+ summary: `Connected training platform changed media buy status from ${prev} to ${status}.`,
+ });
+ await emitAccountChangeRecordedWebhook(principal, change);
+ }
+
return { success: true, previous_state: prev, current_state: status, message: `Media buy ${mediaBuyId} transitioned from ${prev} to ${status}` };
},
@@ -1150,7 +1202,8 @@ function createStore(
async seedCreative(creativeId, fixture) {
const fx = (fixture ?? {}) as Record;
enforceMapCap(session.creatives, creativeId, 'creatives');
- const existing = session.creatives.get(creativeId);
+ const existing = session.creatives.get(creativeId)
+ ?? getSharedAccountCreative(controllerAccountId, creativeId);
const now = new Date().toISOString();
const fixtureFormatId = fx.format_id as CreativeState['formatId'];
const formatKind = (fx.format_kind as string | undefined)
@@ -1164,8 +1217,11 @@ function createStore(
?? (fixtureFormatId || existing?.formatId ? undefined : 'image');
const formatOptionRef = (fx.format_option_ref as Record | undefined) ?? existing?.formatOptionRef;
const formatId = fixtureFormatId ?? existing?.formatId;
- session.creatives.set(creativeId, {
+ const storedCreative: CreativeState = {
creativeId,
+ ...(controllerAccountId && { accountId: controllerAccountId }),
+ ...(controllerAccountRef && { accountRef: controllerAccountRef }),
+ controllerSeeded: true,
...(formatId && { formatId }),
formatKind,
formatOptionRef,
@@ -1174,7 +1230,26 @@ function createStore(
syncedAt: existing?.syncedAt ?? now,
manifest: (fx.manifest as CreativeState['manifest']) ?? existing?.manifest,
pricingOptionId: (fx.pricing_option_id as string | undefined) ?? existing?.pricingOptionId,
- });
+ };
+ session.creatives.set(creativeId, storedCreative);
+ if (controllerAccountId) {
+ upsertSharedAccountCreative(controllerAccountId, storedCreative);
+ const change = recordAccountChange(principal, {
+ resource: {
+ type: 'creative',
+ account_id: controllerAccountId,
+ resource_id: creativeId,
+ },
+ action: existing ? 'updated' : 'created',
+ origin: { kind: 'connected_platform', connection_id: 'conn_shared_training_platform' },
+ changed_paths: existing ? ['/name', '/status', '/manifest'] : undefined,
+ repair: { task: 'list_creatives' },
+ summary: existing
+ ? 'Connected training platform updated a creative.'
+ : 'Connected training platform added a creative.',
+ });
+ await emitAccountChangeRecordedWebhook(principal, change);
+ }
},
async seedPlan(planId, fixture) {
@@ -1288,6 +1363,7 @@ function createStore(
* entry in place during the transition; remove once a release has landed and the
* cross-impl tests no longer rely on it). */
const LOCAL_SCENARIOS = [
+ 'expire_account_change_cursor',
'force_create_media_buy_arm',
'force_get_products_arm',
'force_get_signals_arm',
@@ -1453,9 +1529,12 @@ async function handleCompactLifecycleProbe(
}
function localScenariosFor(ctx: TrainingContext): string[] {
- return ctx.storyboardCompat?.version === '3.0'
+ const scenarios = ctx.storyboardCompat?.version === '3.0'
? LOCAL_SCENARIOS.filter(s => s !== 'force_creative_purge' && s !== 'force_wholesale_feed_webhook' && s !== 'seed_rights_grant' && s !== 'query_provenance_audit_observations')
: [...LOCAL_SCENARIOS];
+ return supportsAccountChangeFeed(ctx.servedAdcpVersion ?? '3.2-beta.6')
+ ? scenarios
+ : scenarios.filter(s => s !== 'expire_account_change_cursor');
}
/**
@@ -1473,7 +1552,10 @@ function localScenariosFor(ctx: TrainingContext): string[] {
* params: { token?, mode?: 'verify'|'revoked_demo'|'wrong_aud_demo',
* tamper?: 'signature'|'sub'| }
*/
-async function handleVerifyGovernanceToken(rawArgs: Record): Promise