Skip to content

feat(protocol): draft account change feed - #6811

Open
bokelley wants to merge 6 commits into
mainfrom
seller-media-source-of-truth
Open

feat(protocol): draft account change feed#6811
bokelley wants to merge 6 commits into
mainfrom
seller-media-source-of-truth

Conversation

@bokelley

@bokelley bokelley commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • define source-neutral authoritative reads: seller UI, seller systems, connected platforms, and AdCP writes converge on the same current-state surfaces
  • add the draft list_account_changes durable account feed, account.change_recorded wake-up, capability declaration, cursor-expiry recovery, privacy boundary, and 90-day retention floor
  • add a shared-account learning lab, capability-gated compliance scenario, and training-agent reference implementation

RFC/epic: #6810

Why a new feed

webhook_activity[] is delivery-attempt diagnostics, task history covers AdCP operations, and snapshots only answer current state. None can durably enumerate changes made while the buyer is idle. The feed is ordered metadata plus a safe repair-read hint; authoritative reads remain current truth.

Scope boundary

The completeness claim covers committed material changes to account-scoped control-plane fields recoverable through an advertised authoritative read. It excludes raw audience members/events, per-impression delivery accumulation, naturally moving forecasts, validation failures, dry runs, exact idempotency replays, and webhook delivery attempts.

Reviewer focus

This draft is ready for protocol review. The highest-value questions are:

  1. Is the completeness boundary correct: every material mutation to a field on
    an advertised authoritative read, but not high-volume data-plane events?
  2. Is one generic list_account_changes feed preferable to per-resource feeds
    or notification-only convergence?
  3. Should every committed record fan out one logical
    account.change_recorded notification to every active subscriber, including
    changes initiated synchronously through AdCP?
  4. Are a 90-day minimum retention window and explicit CURSOR_EXPIRED
    rebootstrap sufficient?
  5. Does the source-neutral coverage matrix set the right bar before a seller
    may advertise each resource family?
  6. Is this foundational enough for 3.2, or should the durable feed move to 3.3
    while 3.2 ships only source-neutral snapshots and invalidations?

The SDK/storage mechanics below are merge gates for the reference deployment,
not blockers to reviewing those protocol decisions. The SDK already provides a
generic PostgresStateStore; the seller runtime still owns durable ordered
records, retention, cursor checkpoints, and atomic state/change commits. A
specialized reusable SDK change-feed store can follow once the wire contract is
accepted.

Draft / merge gates

This PR is intentionally draft and MUST NOT merge until:

  • RFC RFC: complete account change visibility across seller-managed state #6810 completes the minimum seven-day review and is accepted
  • the training seller either persists feed state across restarts or stops
    advertising the capability in its production deployment; an in-memory
    reference cannot claim 90-day retention
  • generated SDKs include the new closed account.change_recorded notification value; the currently installed SDK rejects registration before regeneration
  • end-to-end compliance transport checks validate retry/re-emission identity and
    cursor-expiry recovery (the reference unit suite already covers fanout,
    pause/removal, re-emission identity, total ordering, concurrent append,
    filtered scanning, empty-tail checkpoints, and 90-day expiry)

The training seller now advertises only creative coverage. Its shared-account fixture proves that a creative added through a connected platform while the buyer is idle appears both in list_account_changes and the authoritative list_creatives read. Account, media-buy, money, and reporting coverage remain explicitly unadvertised until their mutation paths and snapshots meet the same bar.

Within creative coverage, the reference implementation now exercises
connected-platform create and status modification, direct sync_creatives,
inline media-buy create/update, purge, shared-account readback, no-op replay
suppression, cross-account isolation, and subscriber fanout. The learning lab
uses both external creation and external modification so this is visibly a
shared account, not a buyer-exclusive sandbox.

Security decisions

  • cursors are opaque and bound to principal, internal seller account scope, and normalized filters
  • equal buyer-visible account IDs do not grant shared access; the training implementation uses an internal seller scope for history and fanout
  • repair.task is a closed allowlist of reads and is only a hint; buyers construct and validate requests locally and never dispatch feed-provided arguments
  • subscriber authorization must be rechecked at fire time and removed immediately when account access is revoked
  • records and webhooks omit credentials, raw payloads, and unbounded before/after state

Validation

  • npm run test:schemas
  • npm run typecheck
  • npm run test:docs-nav
  • npm run test:tenant-routing
  • npm run test:sdk-runner-capability-gates
  • npm run test:error-code-drift
  • npx vitest run --config server/vitest.config.ts server/tests/unit/account-change-feed.test.ts
  • npx vitest run --config server/vitest.config.ts server/tests/unit/training-agent.test.ts
  • npm run test:oneof-discriminators
  • npm run test:error-codes
  • git diff --check

supportedCanonicalFormatsCapability,
} from '../task-handlers.js';
import { GET_PRODUCTS_REJECTED_ADCP_VERSION, supportsGetProductsRejected, type TrainingContext } from '../types.js';
import { GET_PRODUCTS_REJECTED_ADCP_VERSION, supportsAccountChangeFeed, supportsGetProductsRejected, type TrainingContext } from '../types.js';
@bokelley
bokelley marked this pull request as ready for review August 24, 2026 07:54

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Escalate to human review

Escalate — gated schema paths require human/CODEOWNERS approval.

This PR modifies files under static/schemas/source/**, which are under a hard, path-based approval gate (gated_paths: true). The required review status is not APPROVED (unknown/none), so decision-table row 2 fires: the gate is not satisfied and the outcome must be escalate.

The reviewer surfaced no Critical/High/Medium findings — this is a clean additive 3.2 draft (new list_account_changes task, account.change_recorded notification, CURSOR_EXPIRED code, change_feed capability). Schema↔docs coherence, changeset scope/type (correctly additive/Normative), oneOf disjointness, and released-artifact immutability were all verified. Absent the gate, this would approve.

Human/CODEOWNERS review is required on the gated schema-source files before merge.

Escalation reasons:

  • Gated paths touched without an APPROVED review: static/schemas/source/account/list-account-changes-request.json, list-account-changes-response.json, core/account-change-recorded-webhook.json, core/account-change.json, core/account.json, core/notification-config.json, enums/error-code.json, enums/notification-type.json, index.json, protocol/get-adcp-capabilities-response.json — CODEOWNERS/human approval required.

Why human review

  • Gated schema-source files changed (static/schemas/source/**) but review_decision is not APPROVED — human/CODEOWNERS approval required (decision rule row 2).
  • Affected gated files include list-account-changes-request/response.json, account-change*.json, account.json, notification-config.json, error-code.json, notification-type.json, index.json, and get-adcp-capabilities-response.json.
  • This PR touches a path under a hard, non-overridable approval gate (static/schemas/source/account/list-account-changes-request.json (added) matches static/schemas/source/**; static/schemas/source/account/list-account-changes-response.json (added) matches static/schemas/source/**; static/schemas/source/core/account-change-recorded-webhook.json (added) matches static/schemas/source/**; static/schemas/source/core/account-change.json (added) matches static/schemas/source/**; static/schemas/source/core/account.json (modified) matches static/schemas/source/**; static/schemas/source/core/notification-config.json (modified) matches static/schemas/source/**; static/schemas/source/enums/error-code.json (modified) matches static/schemas/source/**; static/schemas/source/enums/notification-type.json (modified) matches static/schemas/source/**; static/schemas/source/index.json (modified) matches static/schemas/source/**; static/schemas/source/protocol/get-adcp-capabilities-response.json (modified) matches static/schemas/source/**) and the current GitHub review decision is 'unknown', not APPROVED. This is a hard gate enforced in code — Ladon cannot auto-approve until a human/CODEOWNERS approval is recorded, regardless of how clean the diff is.

@aao-secretariat aao-secretariat Bot added the ladon/needs-human-review Ladon has escalated this PR for human review. label Aug 24, 2026
"description": "Read a durable, ordered feed of material changes to AdCP-visible state for one account. Use a latest checkpoint before snapshot bootstrap, then drain from that cursor to close the bootstrap race.",
"x-tool-summary": "List durable changes to authoritative AdCP-visible state for one account.",
"type": "object",
"allOf": [

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interoperability concern: this request schema has a top-level allOf. Anthropic MCP tool registration rejects top-level input combinators, which can make the entire tool catalog unavailable rather than only this task. Can the generated SDK/tool schema flatten the version-envelope properties and enforce the cursor/starting-position exclusion at runtime, with a conformance assertion that the emitted MCP inputSchema is a plain root object?


Current authorization controls both snapshots and history. Sellers filter
inaccessible resource changes without revealing their existence or count, but
still advance the scoped cursor across filtered records. Cursors are bound to

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Principal binding prevents cross-principal replay, but what happens when the same principal authorization scope expands? A cursor may already have advanced across changes that were filtered while inaccessible; granting access later would make those resources visible without replaying their earlier changes. I think the contract needs an authorization/scope epoch bound into the cursor, or a normative requirement to invalidate the cursor and force snapshot rebootstrap whenever visible scope changes.

1. reflect the resulting current state on that authoritative read regardless
of whether the change originated through AdCP, a seller UI or API, seller
automation, another authorized principal, or a connected platform;
2. append one immutable account change record in the same commit boundary;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we define the minimum invalidation granularity? One committed operation can update a media buy, several packages, and creative assignments. “One record” is ambiguous between one per transaction, one per resource, and one per repair-read closure. My preference is at least one record per independently repairable authoritative identity, allowing same-commit changes to share an optional batch_id and allowing nested package changes to coalesce under a media-buy invalidation only when get_media_buys fully repairs them.

The seller-wide capability lists resource types the seller can support.
`list_account_changes.source_coverage[]` reports account-specific source
health with a source kind, status, resource types, optional coverage start,
last successful sync, and observed-through watermark.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current needs an operational definition if buyers are expected to distinguish feed catch-up from upstream freshness. Could the capability/coverage contract declare a poll cadence or stale_after, so last_successful_sync_at and observed_through can be evaluated mechanically? Otherwise a connector last observed days ago can still self-label current without violating a testable threshold.

Copy link
Copy Markdown
Contributor Author

SDK adopter follow-up filed: adcontextprotocol/adcp-client#2669. It covers generated types/enums, a typed listAccountChanges() client surface, normalized capability discovery, notification registration and parsing, structured CURSOR_EXPIRED, and flattening the emitted MCP input schema for Anthropic-compatible clients. Durable seller retention and application projection persistence remain runtime responsibilities.

@lukasz-pubx

Copy link
Copy Markdown

Review at 716c5d8 (schemas, spec doc, task doc, enums, capability block, compliance scenario; baseline claims cite the repo tree at the merge-base — the live docs page for media-buy/specification currently lags main).

Overall: strong draft — the tree delivers what the RFC promises, and it's wired unusually completely. The notification enum's own meta-rule (anchor + notification_id semantics + repair key in the enumDescription) is followed to the letter, CURSOR_EXPIRED lands with recovery text and a drift-disposition entry, and the divergence from house pagination (cursor always returned, even on empty pages) is declared rather than smuggled. Three design choices deserve explicit support: the starting_position: "latest" bootstrap-race closure (this is the correct fix for a gap that changed-since filters can't close — speaking as the buyer intermediary that was contemplating exactly such a filter on the forum), the repair-hint-never-dispatched rule, and refusing to weaken snapshot completeness into a complete: true self-assertion.

Four findings:

1. blocker — the coverage matrix promises wholesale change records the schema cannot express

specs/account-change-feed.md coverage matrix: "Wholesale products and signals | list_products / get_signals | Existing versioned feeds stay authoritative. Account changes record control or bulk revisions rather than duplicating every feed entity body." But core/account-change.json makes repair required with a closed repair.task enum of eight reads that excludes list_products and get_signals. A seller advertising product coverage that records a bulk pricing revision per the matrix cannot produce a valid record: it either drops the record (violating the invariant for an advertised type) or mislabels the repair read. Any of these resolves it: (a) add both tasks to the repair.task enum; (b) amend the matrix to state wholesale control changes are not recorded here because wholesale_feed.bulk_change + the versioned feeds already cover them, and exclude those resource types from advertisable coverage; (c) make repair conditional for feed-authoritative families with a stated default. Schema and matrix just need to agree before ratification.

2. warning — cursor/filter mismatch has no defined disposition, and the silent branch loses data permanently

The cursor is "bound to the normalized filter" (request schema; task doc), but nothing defines what a seller MUST do when a cursor arrives with different filters than it was minted under. Two conformant implementations diverge: one rejects with VALIDATION_ERROR; another honors the embedded position and applies the new filters — silently skipping every record of a newly-added resource type older than the checkpoint. On a surface whose reason to exist is completeness, the silent branch is a permanent, undetectable gap. Ask: define MUST-reject with a named code, state it in both the schema description and the task doc, and add a compliance probe for the rejection (the same probe list should cover a cursor presented by a different principal).

3. warning — the commit-boundary invariant is unimplementable for non-mediated changes as literally written

Invariant item 2: "append one immutable account change record in the same commit boundary." For a seller whose authoritative reads pass through to the underlying ad server, a seller-UI or connected-platform change is visible on get_media_buys the moment it lands upstream — before the seller's poller observes it. There is no commit boundary joining read-visibility and record-append for that architecture; read literally, every pass-through implementation is non-conformant even when its feed is correct. The PR's own surfaces already acknowledge ingestion lag (has_more "caught up to the seller's ingestion boundary", source_coverage.observed_through, recorded_at "committed or durably observed") — the invariant hasn't caught up with them. Ask: scope item 2 to the seller's ingestion/projection commit for observed (non-mediated) changes and state that authoritative reads MAY lead the feed within declared source freshness — or, if atomic read/feed coupling is truly intended, say that advertised coverage requires projection-backed reads and accept that this excludes pass-through architectures.

4. warning — the ratified "durable seller-initiated subscription is 4.0 work" statements survive untouched and now mislead

Three normative sentences this PR doesn't modify: docs/media-buy/specification.mdx ("A durable resource-scoped status webhook is reserved for the 4.0 notification model"; "buyers that need immediate push delivery should negotiate an implementation extension until the 4.0 resource-scoped notification contract lands") and docs/media-buy/task-reference/control_media_buy.mdx ("…remains 4.0 work"). This PR ships a durable, account-anchored, seller-initiated change surface in 3.2 whose coverage matrix explicitly includes media buys. The defensible distinction — account-anchored invalidation metadata is not a resource-scoped status webhook — doesn't save the middle sentence: once a seller advertises account.change_feed with media_buy coverage, "negotiate an implementation extension" is wrong advice, and the stalest copy is the one implementers will cite back. Ask: cross-reference all three sites, or state in the spec doc why they deliberately stand unchanged.

Notes

  • The compliance scenario probes webhook fanout and the drain flow but not CURSOR_EXPIRED recovery — the PR's own merge gates already commit to this; the finding-2 mismatch probe would fit the same batch.
  • starting_position defaulting to earliest means a parameterless first call drains up to 90 days of history. Defensible (completeness-first), but worth one sentence confirming it's intentional, since the documented bootstrap workflow leads with latest.
  • On the reviewer-focus questions: Q2 — one generic feed is right; it matches the registry change-feed precedent and avoids the per-resource enum growth the RFC itself argues against. Q4 — 90 days + explicit CURSOR_EXPIRED is sufficient given the no-silent-restart rule, which is the part that matters. Q6 — from the demand side: ship in 3.2 even with creative-only coverage; the bootstrap-race closure and origin attribution have buyer value from day one, and the capability gate means nobody pays for coverage that isn't ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ladon/needs-human-review Ladon has escalated this PR for human review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants