PARTIALLY IMPLEMENTED — Python ships Phase 1+2 (PRs #483, #487 in
adcp-client-python); JS ships the Phase 1 sandbox-authority gate and
the AdCP 3.1 comply-controller visibility rule: live principals do not
see compliance_testing or comply_test_controller, while sandbox/mock
principals can discover the controller and still get a dispatch-time
PERMISSION_DENIED when their request targets a live or unresolved
non-sandbox account. Mock-mode
upstream URL routing remains open. See § Implementation status. Anchors in
docs/architecture/adcp-stack.md (the layered architecture); this doc
is the SDK-side artifact.
Open work tracked in:
- adcp-client#1494 — v6→v7 migration guide stub
- adcp-client#1495 — mock-server fixture scaffolding (per-specialism scenario state machines)
There are three operationally distinct account modes, not two. The adapter code is the same in all three; the SDK selects the upstream URL based on the mode of the resolved account:
account.mode === 'live' → adapter → production upstream (GAM, FreeWheel, Kevel, …)
account.mode === 'sandbox' → adapter → adopter's test upstream (their test infra)
account.mode === 'mock' → adapter → bin/adcp.js mock-server <specialism>
| Mode | What it is | Who owns truth | Use cases |
|---|---|---|---|
| live | Production traffic | Adopter's upstream (GAM, FreeWheel, Kevel, …) | Real money buyers |
| sandbox | Adopter's own test account | Adopter's test infra (test DB, test GAM tenant, etc.) | Adopter's playground — internal QA, demo, integration testing of their code; live-compliance storyboards (planned) certify that the actual deployed agent behaves correctly under storyboards |
| mock | Adapter pointed at mock-server fixture | The mock-server fixture (per-specialism upstream-shaped reference) | Spec compliance — storyboards exercise the adapter against a reference upstream-shaped fixture; agent development without a real upstream; cross-SDK compliance |
Two kinds of compliance, two modes:
- Spec compliance — runs in mock mode. The adapter runs unchanged against the mock-server's per-specialism fixture; storyboards drive scenarios via fixture state. This is what certifies "your SDK + adapter together implement the spec correctly."
- Live compliance — runs in sandbox mode. Storyboards exercise the deployed agent (adopter's code path, against their test infra) to certify the full upstream-to-wire path actually works end-to-end. The storyboards for this aren't built yet — but that's the plan, and the three-mode model leaves room for them.
Adopters get both for free as long as they (1) flag their conformance
account mode: 'mock' for spec compliance, and (2) flag a sandbox
account mode: 'sandbox' for live compliance (when those storyboards
ship). Their DecisioningPlatform code doesn't change for either.
Today, "sandbox mode" conflates two distinct concepts:
- The adopter's test playground (their test data, their code path)
- The compliance harness target (storyboards drive state via
comply_test_controller)
This conflation forces every adopter to ship complyTest: wiring inside
their DecisioningPlatform — even though compliance is a protocol
concern, not a business-logic concern. Adopter feedback: "I don't want
this in my production code." That feedback is correct; compliance
scaffolding doesn't belong in business-logic code.
docs/architecture/adcp-stack.md puts the lifecycle state machine,
idempotency, async-task contract, error catalog, and conformance test
surface all at L3. L4 (the adopter's code) is upstream business logic
only. Compliance certifies L3. L4 should not contain L3 wiring.
If we believe the layered story, the SDK should isolate L3 entirely — including the conformance test surface. Adopters configure modes; the SDK does the rest.
Nothing changes. Adopter's DecisioningPlatform methods handle every
request. SDK helps with envelope shaping, idempotency, validation
(assertMediaBuyTransition), echo (MediaBuyStore), webhook emission.
SDK never claims authority over status — the upstream is truth.
account.mode === 'live' (or undefined / absent — live is the default).
Adopter's DecisioningPlatform runs against test data. Could be a real
DB with test fixtures, a test GAM tenant, a sandbox in their cloud
provider — whatever they want. The SDK's job is the same as in live mode;
the difference is purely in how the adopter has configured their backend.
account.mode === 'sandbox'. Adopter marks accounts in their
AccountStore.resolve implementation.
Live-compliance storyboards run in sandbox mode (planned, not yet
built). They certify the full deployed-agent path: adopter's
DecisioningPlatform methods, against their test infra, end-to-end
under storyboard pressure. Distinct from spec-compliance storyboards
(which run in mock mode and exercise the protocol path only). When
live-compliance storyboards ship, adopters point them at a sandbox-mode
account and certify that their actual code works — not just that the
SDK does.
Adopter's DecisioningPlatform methods run identically to live and
sandbox modes. The difference is the upstream URL: for mock-mode
accounts, the SDK points the adapter at bin/adcp.js mock-server <specialism> instead of the production upstream. The mock-server
provides reference upstream-API behavior per specialism (Celtra-shaped,
GAM-shaped, TikTok-shaped, etc.); the adapter doesn't know it's talking
to a fixture.
account.mode === 'mock'. Adopter does not write a complyTest:
block, does not maintain in-memory seededMediaBuys Maps, does not
gate on process.env.ADCP_SANDBOX. Compliance scaffolding (seeded
media buys, scenario state machines) lives in the mock-server's
per-specialism fixtures — not in adopter code.
How the SDK reaches the mock:
The SDK arranges for the adapter's upstream HTTP client to point at the
mock-server's specialism endpoint. Adapter code is unchanged across
live, sandbox, and mock — only the resolved upstream URL differs per
request, based on account.mode. Cross-SDK compliance is preserved:
every SDK (JS, Python, Go) implements its own framework-side mode-aware
URL routing, but all routes terminate at the SAME bin/adcp.js mock-server <specialism> reference fixtures. The mock-server is the
cross-language referee at the upstream-API layer — the spec normatively
pins the spec → mock → SDK triage order at
Mock-server authority and failure triage.
SDK-specific implementations of "URL routing" may differ in shape
(decorator, base-class method, middleware) but the contract is the
same: when account.mode === 'mock', the adapter's upstream URL points
at the mock-server. Benefits of standardizing on the mock-server as the
reference:
- One reference fixture per specialism, one set of upstream-shape tests. Storyboards drive scenarios via fixture state rather than adopter-side test controllers.
- Cross-language SDKs are first-class. A Python adopter and a TS adopter targeting the same specialism hit the same mock-server instance with the same fixture state.
- Exercises the full network path during conformance — picks up serialization edge cases, header handling, auth on the upstream leg.
Conformance harnesses already shell out to the adcp binary today
(bin/adcp.js storyboard run …), so adopters in any language get the
mock-server invocation as part of running storyboards — no new
operational overhead.
The implementation shape (decorator / base-class method / middleware /
constructor injection / ctx.upstream) is negotiable — each SDK
picks what fits its idiom. The wire contract is non-negotiable:
- Real out-of-process HTTP. The adapter must make a real network
request to the mock-server process. No in-process shortcut, even
when the adapter and the mock-server happen to share a runtime
(e.g., both running inside the same Node process during storyboard
runs). This preserves the original "one wire-correctness contract"
intent — serialization, headers, auth, and
idempotency_keyhandling traverse the network during conformance. RFC 9421 signed requests apply to the buyer→SDK leg only; the SDK→mock-server leg is unsigned, and the mock-server treats any signature headers as opaque pass-through (it does not verify them). - All requests for a mock-mode account, across the entire async
task lifecycle, hit the mock-server. No partial routing where
some methods go to mock and others to a real upstream.
account.moderesolves per (request, account, adapter) — once-per-request for the primary upstream — and persists across the lifecycle of the resulting async task: follow-uptasks/getpolls, webhook emissions, and any downstream notifications all stay on the mock-server. A mock-mode buy must never leak a live webhook URL or a livetasks/getcallback. - Same wire shape across SDKs at the upstream-API layer. The
per-specialism endpoints exposed by
bin/adcp.js mock-serverare the cross-language reference. SDKs do not ship language-private mock implementations whose wire shapes diverge. If a fixture behavior is wrong, the fix lands in the mock-server, not in any SDK's adapter layer. The artifact (Node binary today; possibly Docker image or static binary later) is a packaging choice — Python and Go adopters running production conformance harnesses shouldn't have to take a permanent Node install dependency. The wire shape is the contract; the binary is one valid carrier of it.
Cross-adapter fan-out: account.mode resolves per-(request, account,
adapter), not per-call. A create_media_buy that internally calls
list_inventory then create_order against the same upstream is one
adapter, one resolution — both calls go to the same upstream. Adapters
that compose across specialisms (rare today; possible with
composeMethod) get one resolution per composed adapter.
If an SDK's "idiomatic shape" can't satisfy points 1–3, it's the wrong shape. The point of softening the implementation contract is to let each language pick how it expresses the routing — not to weaken what the routing guarantees.
The user-facing distinction:
- Sandbox: "I want to test my code with test data."
- Mock: "I want to run my SDK without writing any code, against a reference implementation, for conformance."
These are different needs. Conflating them is what produced the
complyTest: wiring problem in the first place — adopters were forced
to teach their codebase the shape of the compliance harness because
they had no other way to satisfy it.
With three modes, the wiring problem dissolves: compliance scaffolding lives in the mock-server's per-specialism fixtures, not in adopter code. The adapter runs unchanged against a fixture-shaped upstream; storyboards drive scenarios through fixture state.
The trust boundary that makes this whole model load-bearing is the
resolved account's mode, returned from platform.accounts.resolve on
the seller side. Nothing else.
In particular, the boundary is NOT:
- The wire
AccountReference.sandboxflag. This is buyer input. A buyer can stuffsandbox: trueinto any natural-key request — the spec defines the field, the schema accepts it, the framework validates it. But buyer-controlled fields cannot be the basis for "is this a test-only context?" because the buyer has every incentive to claim sandbox status when calling test-only surfaces against a live tenant. - The presence of a sandbox-shaped account-id prefix (e.g.,
sandbox_<id>) the seller happens to use. Account-id shapes are internal seller convention; an attacker who learned the prefix could not forge a live account into one that admits the gate, but a seller-side bug that branches on prefix shape can drift from the resolver's actual mode answer. - An environment variable (historically
ADCP_SANDBOX=1). The SDK's legacy bridge accepts this for back-compat, but a process-scoped flag cannot answer "is this caller test-only" — it answers "did the operator run with the sandbox flag set?", which is the wrong scope. The bridge fail-closes once any explicitmode: 'live'account has been observed (@adcp/sdk6.7+ ships this guard; seesrc/lib/server/decisioning/runtime/observed-modes.ts).
The framework gate inside createAdcpServerFromPlatform reads mode
exclusively from the Account object the resolver returns. The
resolver is the seller's authoritative call site for "who is this
caller, and what tenancy do they have access to?" — answered against
the seller's tenant store, keyed by the authenticated principal.
AdCP 3.1 adds a visibility rule on top of the dispatch gate:
production callers must not be able to enumerate the comply controller.
The JS framework applies that rule by resolving the auth-derived
principal with platform.accounts.resolve(undefined, ctx) for
get_adcp_capabilities, tools/list, and direct
comply_test_controller calls. If that principal is not sandbox/mock,
the capability block is omitted, the tool is filtered from tools/list,
and direct calls fail as MCP method-not-found. Once a sandbox/mock
principal can see the controller, the per-request target account is
resolved separately; a target live or unresolved non-sandbox account
returns PERMISSION_DENIED.
Adopters' resolvers MUST NOT spread untrusted input into the resolved account. Specifically, an adopter resolver implementation like:
resolve: async (ref, ctx) => {
return { id: ref.account_id, sandbox: ref.sandbox, mode: 'sandbox', ... };
// ↑ wrong — `mode: 'sandbox'` is buyer-controllable via this shape
}…has effectively put the trust boundary on the wire. The right shape:
resolve: async (ref, ctx) => {
const tenantRow = await myDb.findByCredential(ctx?.authInfo?.credential);
if (!tenantRow) return null;
return {
id: tenantRow.id,
mode: tenantRow.is_sandbox ? 'sandbox' : 'live', // ← from tenant store
ctx_metadata: { ... },
};
}The mode field is sourced from the seller's own tenant store, which
the buyer cannot influence. This is what makes the framework gate's
refusal of live-mode dispatch a real security property rather than a
soft hint.
The same discipline applies to anything else the gate reads
transitively. assertSandboxAccount is exposed for adopters who want
to compose the gate inside custom dispatch paths; its opts.message
field MUST be a static string literal (echoed on the wire inside the
error envelope — interpolating user-controlled values creates a
reflection sink). See the JSDoc on
src/lib/server/account-mode.ts:assertSandboxAccount.
This is the part that makes the model hold up across SDKs.
The mock-server is language-agnostic. It's a separate service (or embeddable library) that ships predictable wire behavior for storyboards. A Python AdCP SDK can route its mock-mode accounts to the same mock. Compliance becomes: "does your wire behavior match the mock's, when the mock drives the same storyboards?"
This is the right shape for a multi-implementation ecosystem. The spec defines the wire; the mock defines the reference implementation; storyboards exercise the reference. Any SDK in any language can hit that bar.
If someone says "I'm not using the SDK, I'll build my agent from
scratch" — fine. They still need to pass conformance. They still hit
the same mock-server (in mock mode for their own implementation, then
verify their L3 logic matches). The mock is the impartial referee — the
spec normatively pins this triage order (spec → mock → SDK) at
Mock-server authority and failure triage.
Smallest, most-load-bearing change. Ships first.
- Add
Account.mode: 'live' | 'sandbox' | 'mock'as a new field (resolved decision; see § Resolved decisions).Account.sandbox: booleaneither stays as a derived accessor for back-compat or gets deprecated outright in a future major. - SDK enforces the deployment-scoped Path B visibility rule:
live/unresolved principals do not see
compliance_testinginget_adcp_capabilities, do not seecomply_test_controllerintools/list, and direct controller calls return MCP method-not-found. - Sandbox/mock principals see the controller normally. Once visible,
target-account dispatch still requires the target account to resolve
to
mode: 'sandbox' | 'mock'; live or unresolved non-sandbox targets returnPERMISSION_DENIED. - Legacy resolved
{ sandbox: true }accounts are treated as visible during the migration window, matching the SDK's existing account-mode helper. - Legacy fallback is intentionally narrow:
ADCP_SANDBOX=1exposes the controller for older conformance deployments until explicitAccount.modeis wired. It fails closed if the process has resolved a live account. Theaccount.sandboxwire flag is consulted only for an unresolved target-account ref, never for principal visibility and never over a resolved live account. - Migration note: adopters running the conformance harness with
ADCP_SANDBOX=1and otherwise un-flagged accounts must mark conformance accounts in theirAccountStore.resolveimplementation.
The adopter-cleanup phase. Compliance becomes inherited.
- The SDK detects
account.mode === 'mock'on the way into tool dispatch. For mock-mode requests, the adapter's upstream HTTP client base URL is swapped to the mock-server's specialism endpoint. Adapter code runs unchanged. - The
comply_test_controllertool is unaffected by mock-mode routing. It's an SDK-side compliance concern; real upstream APIs don't have it, mock fixtures don't either. Adopters wireTestControllerStore(or equivalent) once for all three modes. - Compliance scaffolding (seeded media buys, scenario state machines) lives in the mock-server fixtures, NOT in adopter code. The mock-server's per-specialism shape is what makes this possible — the mock IS the upstream-shaped reference, so storyboards drive scenarios via fixture state rather than adopter-side test controllers.
- Cross-SDK contract: each SDK (JS, Python, Go) implements its own SDK-side mode-aware URL routing. The mock-server is the language-neutral referee at the upstream-API layer.
- Hello adapter cleanup: delete
seededMediaBuysMap, deletecomplyTest:block, deleteprocess.env.ADCP_SANDBOXchecks. Adopter file shrinks by ~50-80 LOC. The example becomes a clean L4-only file. Prerequisite: the per-specialism mock-server fixtures must actually carry the scaffolding the adapter is delegating to (see § Mock-server fixture scaffolding below).
Mock-mode URL routing presumes the adapter resolves its upstream
client per-request, not at construction time. The contract for
JS adopters is ctx.upstream: the SDK resolves the mode-aware
upstream client and hands it to the adapter via RequestContext.
The existing (req, ctx) handler shape is unchanged:
import { defineSalesPlatform } from '@adcp/sdk/server';
export const salesPlatform = defineSalesPlatform({
createMediaBuy: async (req, ctx) => {
// ctx.upstream — bound to GAM_BASE_URL in live, sandbox URL in
// sandbox, mock-server's specialism endpoint in mock.
const { order_id } = await ctx.upstream.post('/orders', toGamOrder(req));
return { media_buy_id: order_id, status: 'active' };
},
});Other SDKs may pick different shapes (Python ships
upstream_for(ctx) -> UpstreamHttpClient; Go is TBD). The
cross-language contract is "upstream URL is per-request resolvable";
the language-local idiom is whatever fits.
Vendor-SDK-wrapped adapters keep complyTest: as a first-class
path. Adopters who wrap a vendor SDK that takes URL in the
constructor (GAM, FreeWheel, Kevel, Celtra, TikTok Marketing API)
have two supported options:
- Refactor to lazy per-URL construction. Cache vendor SDK instances keyed on resolved upstream URL — small factory, ~6 LOC, no rewrite of the vendor SDK. A worked recipe lives in the migration guide stub (adcp-client#1494). Mock mode then works for them.
- Keep
complyTest:and skip mock mode. Live + sandbox keep working as today. ThecomplyTest:block stays a supported, documented path — not a Phase-3 escape hatch. Compliance via sandbox-mode storyboards (when those ship) covers the same ground for adopters whose sandbox is realistic enough.
Adopters who don't need mock mode (live + sandbox only) are unaffected. The Phase 2 cleanup of the hello adapter is a demonstration of the refactor path, not a forced migration.
Phase 2's "compliance scaffolding lives in fixtures, not adopter code" claim presumes the mock-server actually carries that scaffolding for each specialism. Status as of this writing:
- Exists today:
bin/adcp.js mock-serverruns per-specialism upstream-API REST fixtures (Celtra-shaped, GAM-shaped, TikTok-shaped, default port 4500). Static request/response shapes are in place. - Exists now: the mock-server has a per-specialism scenario
controller, exposed through the programmatic boot handle and HTTP
/_scenario/*routes protected by the generatedX-Mock-Control-Token. It provides fixture-state snapshots, reset between storyboard runs, one-shot scripted responses for authenticated fault-injection tests,idempotency_keyexact replay/cache-conflict handling on state-creation fixture routes, and loopback-only webhook emission/capture stubs. Per-specialism fixtures still own their business state machines (orders, activations, renders, conversations, etc.), but the shared controller is the scaffolding storyboards use to drive and clean them.
Owners of new specialisms adding mock-server fixtures should plan for the scenario-driving surface area, not just the static request/response shapes.
Tracked at adcp-client#1495.
complyTest:option stays available. If the adopter supplies it, their handlers run in addition to the SDK's mock-mode defaults (or instead, if they explicitly opt out). This covers adopters whose sandbox needs differ from the mock's predictable defaults (e.g., a sandbox that simulates upstream-specific edge cases).- Most adopters don't need this. The default path is "do nothing, inherit compliance."
- Not "the SDK becomes a mock seller." In live and sandbox modes the adopter's code runs as today. Mock mode is a routing decision, not a behavior change for live traffic.
- Not breaking for v6 adopters. Existing
complyTest:callers keep working; they just become optional. - Not Postgres-store-for-production. The mock-server has its own state (in-memory by default). It's not a parallel persistence layer for adopter business data.
- Not a clock-driven status advancer in production or sandbox. Auto-advance is mock-server logic, scoped to mock-mode accounts. Live and sandbox traffic comes from the adopter's upstream / test infra — they own their own clock semantics.
"I implement upstream calls. The SDK handles the protocol envelope. Compliance is something I get for free by using the SDK — I run a conformance harness against a mock-mode account, and it passes because the SDK routes it to a reference implementation. I don't write compliance code."
That's the pitch. The architecture above is what backs it up.
- Adopters writing
complyTest:blocks — they shouldn't have to. Compliance is L3; their code is L4. - The SDK lying about production state — mock mode owns mock state; live mode defers to upstream truth. No reconciliation drama.
- A new framework to learn —
DecisioningPlatformkeeps its shape. Mode routing is configuration, not a code redesign.
These were open questions in earlier drafts; product-owner direction captured here for traceability.
- Account.mode encoding:
Account.mode: 'live' | 'sandbox' | 'mock'as a new field, not a tri-state extension ofAccount.sandbox: boolean. Clearer at the call site;Account.sandboxcan stay as a derived/computed accessor for back-compat where it's already wired, or be deprecated outright in a future major. - Mock-mode routing contract: SDK-side, mode-aware upstream URL
routing. When
account.mode === 'mock', the resolved upstream URL points atbin/adcp.js mock-server <specialism>. Implementation shape per language is open; wire contract is fixed. Full enumeration of negotiable vs. non-negotiable lives in § What's negotiable vs. non-negotiable. - Mock-server packaging: ships in
bin/adcp.jstoday, since all SDKs already invoke that binary for storyboard runs. The wire shape is the contract; the packaging (Node binary today; Docker or static binary later for Python/Go adopters who don't want a Node dep in production conformance) is a follow-up choice, not a v6→v7 blocker. - AdCP spec docs: the layered architecture
(
docs/architecture/adcp-stack.md) moves toadcontextprotocol/adcpso it's the cross-SDK reference. This proposal stays inadcp-client/docs/proposals/as the SDK-side artifact for routing and account-mode semantics.
- Python (
adcontextprotocol/adcp-client-python): Phase 1 shipped in PR #483 (Account.mode+ comply-controller gate). Phase 2 shipped in PR #487, usingDecisioningPlatform.upstream_url: ClassVar[str | None]for the production URL plusDecisioningPlatform.upstream_for(ctx)to return a cachedUpstreamHttpClientper request based on resolvedaccount.mode. Per-tenant mock URL contract:Account.metadata['mock_upstream_url'], populated byAccountStore.resolvefor mock-mode accounts. Mix-and-match works in one process: live + sandbox + multiple mock tenants on different fixture URLs. - JS (
adcontextprotocol/adcp-client): Phase 1 shipped in PR #1453 (comply-controller auto-wire). Phase 2 not yet implemented; reference shape is the Python implementation linked above. The contract — whenaccount.mode === 'mock', the adapter's upstream URL points at the mock-server's specialism endpoint — is the same; the JS-idiomatic shape (decorator, base-class method, middleware) is an implementation choice for that PR.