Repository navigation
Description
This request for comments (RFC) asks the working group (WG) for a direction. It merges no schema
change. The illustration pull request (PR), #8358 (draft, not for merge), on the branch
proposal/modular-adcp, holds three things:
- The walkthrough,
docs/reference/proposals/modular-adcp.mdx.
It explains the design and holds the full reference: every list, count, and file move, and how to
reproduce each number. - The presentation: two decks with the same examples, in
examples/modular-extensions/presentation/. To view them, check out theproposal/modular-adcp
branch and openexamples/modular-extensions/presentation/index.htmlin a browser. PR docs: propose a modular AdCP with layers, modules, and extensions #8358 gives
the exact commands. - A runnable prototype and the analysis scripts, in
examples/modular-extensions/.
Motivation
How does the Ad Context Protocol (AdCP) take changes such as #8102, #7955, and added pricing models
without growing core every time? Each such change lands in core, because core has no other place
for it:
- Seller allowances (feat(media-buy): seller offers on accepted buys #8102).
commercial_termsis closed. feat(media-buy): seller offers on accepted buys #8102 adds seller-heldchange_terms
and edits 16 core schema files. - Make-goods. Core can name a remedy type, not a cap, a window, or what must stay the same.
A signed make-good rule needs fields that core lacks. Make-goods belong to guaranteed buying, which
the WG owns, but core is the only place they can land today. - Rate cards (feat(schema): selection-dependent price_adjustments on fixed-price pricing options #7955). feat(schema): selection-dependent price_adjustments on fixed-price pricing options #7955 adds
pricing-options/price-adjustments.jsonto core pricing options. - Pricing models.
pricing_modelis a closed enum. Revenue share (feat(media-buy): add revenue-share pricing #6095) edited 12 schema files
in 8 areas.
The cost comes from the protocol's structure, not from any one feature:
- One version for everything. 1,047 schema files ship as one unit. From v2.5.1 to v3.2.5, the
version moved 65 times, and every implementer saw all 65. - No boundaries. 136
$refpointers cross from one functional area into another. - Experiments land in stable schemas. The changes that introduced 26 of the 27 experimental
feature IDs edited 83 stable schemas.
The proposal does three things, in order:
- Layers. Rearrange the schemas so that each layer depends only on the layers below it, never on
a layer above. That gives the protocol encapsulation. - Modules. With encapsulation, each module owns its schemas and decides where it can be extended.
- Extensions. Vocabulary that core lacks arrives as a JSON Schema file wrapped in an envelope
and versioned by its digest. It attaches only where a module allows.
This gives two results:
- Less churn between releases. Each module changes on its own and owns its own lists, such as its
error codes. - Added functionality lands in modules and extensions, not in the core. The core stays stable.
It costs nothing on the wire: no existing payload changes a byte, and 3.4 adds optional fields only.
The software development kit (SDK) experience gets better, not worse: the generated binding gives
plain typed attributes.
Scope
The scope has two parts. Part 1 is step 1; Part 2 is steps 2 and 3. The walkthrough covers each in
detail. A hook is a place where a module lets extensions attach: an extension point, an open union,
or the kernel's task union.
Part 1: Layers.
- A kernel layer holds the transaction lifecycle, the envelope, and the shared IDs and data
formats: 135 files. - Nine standard modules and a compliance-testing leaf each reference only the kernel layer.
Modules name each other by kernel ID types, such asaccount_ref. - Buying has eight layers that depend only downward: targeting, pricing, catalogs, commitment,
supply, offer, execution, and delivery. - Four byte-preserving fixes remove all 136 crossing references in 3.4:
- A re-type to a kernel ID (58).
- A bridge (50). One module marks a bridge point. The build composes the other module's definition
into the published schema, under the property name that the payload already uses. - A move with an alias (23).
- The 3.x compatibility assembly (5).
- A blocking check runs star, layer, and acyclic tests on every
$ref. From 3.4, continuous
integration (CI) enforces zero violations.
Part 2: Modules, hooks, and extensions.
- A module is one versioned unit, with its own schemas, tasks, capability fragment, version, and
frozen digest. It releases without moving the kernel or any other module. - Ownership is separate from attachment. The WG owns the standard modules. Guaranteed buying,
with its make-good and unit vocabulary, is one of them. A third party owns its own extensions.
Both attach only at hooks that the host module declares. - Modules negotiate through one optional
modulesmap inget_adcp_capabilities. The map holds
standard modules and buying layers only. A release is a bundle: one number resolves to every module,
layer, and extension version. - A module opens hooks: extension points (
x-adcp-extension-point), open unions
(x-adcp-open-union), and, in the kernel, onetaskunion for contributed tasks. Adding a hook is
an additive minor release of that one module. - An extension attaches in the extension envelope
{uri, digest, data}. The kernel defines the
envelope once. Each extension defines what goes inside. Every extension follows four steps: mark
the extension point, write the external file, wrap in the envelope, and validate in two passes. - Must-understand and negotiation. Each hook sets a policy:
always,flag, ornever. The
seller declaresdeclared_extensions, the buyer listsunderstood_extensions, and the two sides
match entries by exacturi@digest. - Generated binding. An app lists its extensions in
adcp.config.jsonand generates its own typed
layer. Extension data is a plain attribute, and the core SDK package never changes.
Buying's commitment layer marks the extension point on media-buy/commercial-terms.json, at
/properties/extensions:
"extensions": {
"type": "array",
"items": { "$ref": "/schemas/core/extension-entry.json" },
"minItems": 1,
"x-adcp-extension-point": { "name": "commercial_terms", "must_understand": "always", "order": "uri" },
"description": "Binding extension data…"
}On the wire, a make-good rule travels as one entry at proposals[0].commercial_terms.extensions[0]
of the refine_proposals finalize response. The make-good vocabulary is the WG's; the example shows
how vocabulary attaches at an extension point, and a third-party extension attaches the same way. The
lines marked + are what the attachment adds:
{
"brand": { "domain": "brand.example.com" },
"purchases": …,
"start_time": "2027-03-10T17:00:00Z",
"end_time": "2027-03-31T17:00:00Z",
"total_budget": { "amount": 24000, "currency": "EUR" },
+ "extensions": [
+ {
+ "uri": "https://extensions.adcontextprotocol.org/makegood-units",
+ "digest": "sha256:0debf79a7390…",
+ "data": {
+ "rules": [
+ …,
+ {
+ "rule_id": "mg_video_2",
+ …,
+ "max_units": 1,
+ "window": { "interval": 30, "unit": "days" },
+ "keep_same": ["creator", "platform"],
+ …
+ }
+ ]
+ }
+ }
+ ]
}In the SDK, the buyer reads proposal.commercial_terms.makegood_units.
The following table gives the release plan.
| Release | What ships | Wire |
|---|---|---|
| 3.3 | The planned 3.3 fixes. No schema moves. Market vocabulary in flight, such as #7955, stays out of closed core schemas. | no change |
| 3.4 | The extension mechanism, the hooks, the modules map, and every byte-preserving fix. Zero violations, enforced in CI. |
optional fields only |
| 4.0 | The four slated removals and the 3.x compatibility assembly. | 4 slated removals |
Alternatives considered
- Inline fields: the extension's fields placed on the host object under a namespaced key. Not
chosen: every host must open up, the key carries no version, and a strict boundary drops it
silently. - Runtime binding: one shared SDK, a registry passed at parse time, and typed accessors. Not
chosen: every parse must carry the registry, and types reach code only through the accessor. It
stays the fallback for runtime plug-ins. - Keep the experimental lane, with deadlines (non-obvious). Give each experimental ID a clock to
graduate or leave. Not chosen: it ends lingering, but added vocabulary still lands in stable core,
and every implementer still sees every release.
Compatibility impact
Non-breaking. No existing payload changes a byte.
-
3.4 adds optional fields only. Each appears only when the parties use it:
understood_extensionson requestsdeclared_extensionsinget_adcp_capabilitiesextensionsoncommercial_terms, on pricing options, and on hold entriesextensions_stateon each media buy inget_media_buys- The
modulesmap inget_adcp_capabilities - Extension branch IDs as
pricing_modelvalues, with the branch body inextension_model
Moved schemas keep their
$idvalues as aliases, and the published schemas validate the same
payloads. -
4.0 removes only what the repository already slates. The four removals are the following:
- The
create_media_buyfacade and its responseaccount - Inline package
creatives[](4.0: decouple content provenance from media-buy and asset schemas #6423) - The legacy
get_productsproducts and proposals - The legacy product's signal listings
Modularity requires no 4.0 wire change. The walkthrough lists 17 optional cleanups; each removes
behavior and needs its own decision. - The
The SDK surface changes. No implementer has to act to keep working.
- Apps that use extensions list them in
adcp.config.jsonand generate their own typed layer.
Extension data becomes a plain attribute, such asproposal.commercial_terms.makegood_units. - The core SDK package never changes when an app adds an extension. Each extension ships its own
package, generated from its schema. - SDKs check the
modulesmap against the app's lock, and refuse a task whose module the seller
doesn't offer. - SDKs can ship one package per module behind their 3.x umbrella package names (proposed).
Reviewer checklist
- Motivation is clear and not redundant with existing functionality
- Scope is specific enough to implement without further clarification
- Alternatives section covers at least one non-obvious alternative
- Compatibility impact accurately states breaking vs. non-breaking
- Wire-format or schema snippet included (for schema or task changes)
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
bokelley commentedon Oct 24, 2026
Thanks, Constantine. The analysis and prototype are unusually rigorous, and they changed how I think about this. Here's where I've landed, and what we're starting for 3.3.
Part 1 (layers): accept the check, not the restructuring
The dependency check is good hygiene, and we're starting it now in #8453. It's keyed to the domains AdCP already declares (
supported_protocols). Ownership lives in a separate file,scripts/schema-ownership.json, so no published schema changes. Domains follow a DAG with an acyclicity check rather than a star, somedia_buycan build oncreative. The 40 crossings on main today are frozen in a baseline that only shrinks, and CI fails on any new one.We're not adopting the rest of Part 1:
brand_kit_override.logowould generate an anonymous intersection orImageAsset1instead ofImageAsset, which brings back the duplicate-type bug fixed in 3.0.2. Fix Rename Audience Activation Protocol to Signals Activation Protocol #6 (forecast-point→ "forecast_rangekeyed bymetric_id") is a semantic change:forecast-pointcarriesdimensions,budget, andviewability, which a metric-keyed range can't hold.$idrenames. The build and@adcp/sdkboth rely on$idmatching the file path, and the prototype doesn't implement the aliases.extensions/. Many of the 136 crossings exist only because of where the proposed layout puts files. With domain-based ownership, most of them aren't crossings.Part 2 (modules and extensions): AdCP already has the axes
As we discussed on Slack, make-goods and units are part of guaranteed buying, which the WG owns. They aren't extensions. Please update the RFC to say so, and drop the 3.3 hold on #8102 and #7955.
On the module boundary: I did a field-by-field tagging pass over
product.jsonand the pricing options. Neither a new module taxonomy nor specialisms is the right key. The keys are capability declarations, and nearly all of them already exist:media_buy.supported_pricing_modelschannels/ portfolioconversion_tracking,vendor_metric_optimization,trusted_matchexperimental_featuresfloor_price,price_guidance,cancellation_policySpecialisms mix three axes (commitment, channel, buying mode), and none implies another.
sales-guaranteedactually tests human IO approval, not guaranteed vocabulary.fixed_pricecan't be tagged guaranteed: 11 of 36 non-guaranteed fixture options are fixed-price (broadcast scatter, PMP). Your "guaranteed ⇒ fixed_price" does hold (31 of 33), but as a schema rule it's a tightening, so it belongs in 4.0 or a storyboard lint. (Your fixed/flooroneOfalready exists oncanonical-pricing-option.json.)So the 3.3 proposal is #8452 (RFC) / #8454 (implementation):
media_buy.supported_delivery_types, mirroringsupported_pricing_models.x-adcp-applies-when, which keys optional fields to those declarations.That gives a seller what the module idea was after. A non-guaranteed-only MCP surface is a projection of one canonical schema, computed from the seller's own
get_adcp_capabilities: one schema, one SDK, and no seller-specific packages. Projections may only remove optional fields, and payloads don't change.What I'd keep from Part 2
Must-understand semantics for binding terms are a real gap. Today
extis ignore-if-unknown, which is wrong for signed commercial terms. I'd pursue it as a must-understand list on the existingext(like the JWScritheader) for genuinely third-party vocabulary, rather than as a second envelope.KonstantinMirin commentedon Oct 25, 2026
Thanks, Brian. The close read and the field-by-field tagging pass are useful. Where I land:
change_terms[], which resolves my concern.forecast-point: agreed, that fix changes meaning.$idmoves, and the re-types: I replied on build(schemas): fail CI on new cross-domain schema refs #8453. In short: strict for new references, relaxed for what exists, shared split into levels, and the moves done with aliases instead of deferred.build(schemas): fail CI on new cross-domain schema refs
KonstantinMirin commentedon Nov 3, 2026
Hi Brian,
I'd like to come back to Part 1 of this RFC, because I've kept thinking about it since Oct 25, and working through #8547 and
commercial-termshas made me more convinced. You've moved this protocol further and faster than anyone could have expected, and I know you welcome disagreement, so I'd like to share where I've landed.I believe the layering of the schemas is the root cause of most of the pain we keep running into, including the codegen problems and how much a fairly plain change costs. I see the dependency check in #8453 as a good and necessary start. But I don't think of this as hygiene. I think it's a necessity. The principles of abstraction, layers and components isolated from each other hold regardless of who writes the software and which language it's written in, and here that language is JSON Schema. More agents and more guards help us catch problems faster, but they don't make this go away, because the structure is what produces them.
So my position is that the schemas should be split into layers and independent parts, with strict boundaries between them. I'm not attached to how we get there. Star or DAG, the file layout, the
$idmoves and even the word "module" are all open. The boundary is the part I'd hold to.I've put the full evidence here: https://gist.github.com/KonstantinMirin/deeacc9669d0c8c06ab2228acda01f09. The short version:
coreis 45% of the tree, and 33 of its files reach back intomedia-buy,formats,pricing-options,creativeandsignals.coreandmedia-buydepend on each other, andmedia-buypulls in 382corefiles.amount. schemas: give every repeated constraint one definition, and fail CI on any repeat (#8525) #8536 has replaced over 1,000 inline scalar sites so far.commercial-termsas a module and had it audited, and it turned out not to be one. A signed field can arrive through an open core schema, verify against the digest, and never reach the must-understand check. Acritlist would have the same gap. The payloads are in the evidence.That's also why I don't think capability keys answer this on their own. They tell us when a field is relevant, which is useful, but they don't give anyone a boundary to stand on.
On how extensions should work, I think an additional schema that overlays fields on top of an existing one, without changing it, is a much better fit for how fast this protocol moves, because core stops changing with every feature. It has real limits, and I've put those in the evidence too: for one, an overlay can add beside a core value but can't replace it.
I also want to speak to the concerns you raised, because I take them seriously:
$refandallOf, and decision(registry): how should discovered agents surface member-publisher endorsement (#3538 Problem 6) #3547 in adcp-client shows that's doable. My sense is that we're already paying far more to work around the schemas than this would cost.$idmoves. I'd do them with aliases, or not at all if we find a cleaner way. They aren't what I'm arguing for.Here's what I think has to happen, however modularity ends up looking:
core, building on your build(schemas): fail CI on new cross-domain schema refs #8453.coredepends on nothing above it: those 33 files get fixed, and domain schemas move out.sponsored-intelligence,collection,propertyandbranddon't reachmedia-buyat all today. Commerce would come last.Steps 1 to 3 don't change the wire, so I think they fit your plan for 3.3 and 3.4. Step 4 may too, but I haven't checked that yet. So I'm not asking to slow 3.x down. I'm asking whether we can agree this is the shape of the 4.0 break, and treat steps 1 to 4 as work toward it rather than as cleanup.
Could we put this on the agenda for Nov 9? You've seen far more of how this plays out in practice than I have, so I'd really like to hear where you think I'm wrong. I'm happy to do most of the work, and the first three PRs are already up.