Skip to content

RFC: Modular AdCP: layers, modules, and extensions #8359

Open

Description

@KonstantinMirin

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 the proposal/modular-adcp
    branch and open examples/modular-extensions/presentation/index.html in 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:

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 $ref pointers 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:

  1. 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.
  2. Modules. With encapsulation, each module owns its schemas and decides where it can be extended.
  3. 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 as account_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 modules map in get_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, one task union 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, or never. The
    seller declares declared_extensions, the buyer lists understood_extensions, and the two sides
    match entries by exact uri@digest.
  • Generated binding. An app lists its extensions in adcp.config.json and 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_extensions on requests
    • declared_extensions in get_adcp_capabilities
    • extensions on commercial_terms, on pricing options, and on hold entries
    • extensions_state on each media buy in get_media_buys
    • The modules map in get_adcp_capabilities
    • Extension branch IDs as pricing_model values, with the branch body in extension_model

    Moved schemas keep their $id values 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:

    Modularity requires no 4.0 wire change. The walkthrough lists 17 optional cleanups; each removes
    behavior and needs its own decision.

The SDK surface changes. No implementer has to act to keep working.

  • Apps that use extensions list them in adcp.config.json and generate their own typed layer.
    Extension data becomes a plain attribute, such as proposal.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 modules map 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)

Activity

  1. added
    rfcProtocol change — auto-adds to roadmap board
    on Oct 20, 2026
  2. bokelley commented on Oct 24, 2026

    @bokelley
    Contributor

    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, so media_buy can build on creative. 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:

    • "Compose" re-types. These change the published schemas and the generated types, even though payload bytes don't change. For example, brand_kit_override.logo would generate an anonymous intersection or ImageAsset1 instead of ImageAsset, 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_range keyed by metric_id") is a semantic change: forecast-point carries dimensions, budget, and viewability, which a metric-keyed range can't hold.
    • The 695 namespaced $id renames. The build and @adcp/sdk both rely on $id matching the file path, and the prototype doesn't implement the aliases.
    • Bridges and GA files under 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.json and 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:

    Fields Key
    pricing-option arms media_buy.supported_pricing_models
    channel-specific product fields product channels / portfolio
    conversion_tracking, vendor_metric_optimization, trusted_match the capability blocks of the same name
    experimental fields experimental_features
    floor_price, price_guidance, cancellation_policy missing: no supported-delivery-types declaration

    Specialisms mix three axes (commitment, channel, buying mode), and none implies another. sales-guaranteed actually tests human IO approval, not guaranteed vocabulary. fixed_price can'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/floor oneOf already exists on canonical-pricing-option.json.)

    So the 3.3 proposal is #8452 (RFC) / #8454 (implementation):

    • media_buy.supported_delivery_types, mirroring supported_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 ext is ignore-if-unknown, which is wrong for signed commercial terms. I'd pursue it as a must-understand list on the existing ext (like the JWS crit header) for genuinely third-party vocabulary, rather than as a second envelope.

  3. KonstantinMirin commented on Oct 25, 2026

    @KonstantinMirin
    CollaboratorAuthor

    Thanks, Brian. The close read and the field-by-field tagging pass are useful. Where I land:

  4. KonstantinMirin commented on Nov 3, 2026

    @KonstantinMirin
    CollaboratorAuthor

    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-terms has 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 $id moves 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:

    • We don't have a stable layer yet. core is 45% of the tree, and 33 of its files reach back into media-buy, formats, pricing-options, creative and signals. core and media-buy depend on each other, and media-buy pulls in 382 core files.
    • Concepts get rewritten instead of referenced. Money is declared inline 26 times with three different rules for 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.
    • The downstream cost is high. The TypeScript generator carries 57 post-processing functions, and the Python one is an 8,688-line fix-up file. Composing one envelope in fix(schemas): compose the protocol envelope on every task response (#8518) #8547 broke both. One of our own tests now keeps a schema inline because the SDK's postprocessing needs it.
    • Plain changes cost a lot. One optional echo field touched 36 files, and composing the envelope touched 201. About 40% of the schema files have changed since Oct 1.
    • Must-understand can't quite deliver its promise on top of this. I built commercial-terms as 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. A crit list 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:

    • Re-types bringing back duplicate generated types. That bug was real, and I don't want it back either. I think the fix belongs in generators that follow $ref and allOf, 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.
    • $id moves. I'd do them with aliases, or not at all if we find a cleaner way. They aren't what I'm arguing for.
    • One schema, one SDK. I don't think a strict boundary means seller-specific packages. It can still be one SDK.

    Here's what I think has to happen, however modularity ends up looking:

    1. One definition per concept, with a check so copies don't come back (schemas: give every repeated constraint one definition, and fail CI on any repeat (#8525) #8536).
    2. A small set of shared types for money, IDs and envelopes (build(schemas): inventory value concepts and flag structural twins #8504).
    3. Dependency direction enforced in CI, including through core, building on your build(schemas): fail CI on new cross-domain schema refs #8453.
    4. core depends on nothing above it: those 33 files get fixed, and domain schemas move out.
    5. A clear boundary around anything signed.
    6. Then we cut the first part where the boundary is already nearly one-way. sponsored-intelligence, collection, property and brand don't reach media-buy at 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    rfcProtocol change — auto-adds to roadmap board

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions