Skip to content

feat(decisioning): mock-mode upstream URL routing (Phase 2)#487

Merged
bokelley merged 1 commit into
mainfrom
bokelley/feat-mock-mode-dispatch
May 3, 2026
Merged

feat(decisioning): mock-mode upstream URL routing (Phase 2)#487
bokelley merged 1 commit into
mainfrom
bokelley/feat-mock-mode-dispatch

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 3, 2026

Summary

Phase 2 of the lifecycle-state-and-sandbox-authority work. When a resolved Account has mode='mock', the framework points the adapter's UpstreamHttpClient at the per-tenant mock-server fixture URL declared on account.metadata['mock_upstream_url']. Adapter business logic runs unchanged across all three modes; only the upstream URL changes per request.

Reframe vs. earlier proposal sketches. The proposal doc envisioned the SDK forwarding mock-mode tool calls out-of-process to a bin/adcp.js mock-server AdCP-MCP forwarder. The earlier agent on this work correctly determined that doesn't fit the existing mock-server (it's a per-specialism upstream-API REST fixture, not an MCP forwarder). The user (proposal author) confirmed the right model: the adapter knows its own production URL (GAM is always googleads.googleapis.com, Kevel is always api.kevel.co); mock-mode just swaps the upstream URL the adapter's HTTP client points at. Adapter code runs unchanged.

Surface

  • DecisioningPlatform.upstream_url: str | None — adopter declares the production upstream URL on their subclass (e.g. "https://googleads.googleapis.com"). None is allowed for pure in-process platforms.
  • DecisioningPlatform.upstream_for(ctx, *, auth=None, default_headers=None, timeout=30.0, treat_404_as_none=True) -> UpstreamHttpClient — returns a cached client pointed at the right URL for ctx.account.mode. Fail-closes with AdcpError(code='CONFIGURATION_ERROR', recovery='terminal') when:
    • mode='mock' and metadata['mock_upstream_url'] is missing/empty/non-string.
    • mode='live' / mode='sandbox' and platform.upstream_url is None.
  • get_mock_upstream_url(account) -> str | None — type-safe accessor that handles dict, non-mapping, missing, empty, and non-string cases.

Cache key: (base_url, id(auth)), per-platform-instance. Different platform instances don't share clients (no cross-adapter auth-header leak); different auth strategies on the same platform get distinct clients (the auth is injected at construction and can't be swapped per-request from a cached client).

What ships

File Change
src/adcp/decisioning/account_mode.py Add get_mock_upstream_url(account) helper.
src/adcp/decisioning/platform.py Add upstream_url class attr + upstream_for(ctx) method + per-instance cache.
src/adcp/decisioning/types.py Document metadata['mock_upstream_url'] contract on Account docstring.
src/adcp/decisioning/__init__.py Export get_mock_upstream_url.
tests/test_upstream_for.py 22 tests — routing, fail-closed, cache identity, auth threading.
tests/test_error_code_conformance.py Allowlist CONFIGURATION_ERROR (server-side adopter-misconfig signal, distinct from INVALID_REQUEST and SERVICE_UNAVAILABLE).
examples/hello_mock_seller.py Single platform with four accounts demonstrating live, sandbox, mock-A (port 4500), mock-B (port 4501).
docs/handler-authoring.md New section: "Account modes and mock-mode upstream routing".

Hello-mock four-account example

class HelloMockPlatform(DecisioningPlatform, SalesPlatform):
    upstream_url = "https://example-ad-server.invalid/api"

    accounts = ExplicitAccounts(loader=lambda aid: {
        "acct_live":    Account(id=aid, mode="live"),
        "acct_sandbox": Account(id=aid, mode="sandbox"),
        "acct_mock_a":  Account(id=aid, mode="mock",
                                metadata={"mock_upstream_url": "http://localhost:4500"}),
        "acct_mock_b":  Account(id=aid, mode="mock",
                                metadata={"mock_upstream_url": "http://localhost:4501"}),
    }[aid])

    async def get_products(self, req, ctx):
        client = self.upstream_for(ctx)  # base_url depends on ctx.account.mode
        ...

Mock-server lifecycle is not managed by the SDK. Adopters or CI start bin/adcp.js mock-server <specialism> and populate the URL on mock-mode accounts in their AccountStore.resolve.

What's NOT in this PR

References

Test plan

  • pytest tests/test_upstream_for.py -v (22 tests pass)
  • pytest tests/ full regression (3604 passed, 32 skipped, 0 failed)
  • ruff check src/ tests/test_upstream_for.py examples/hello_mock_seller.py
  • mypy src/adcp/ (768 source files, 0 errors)
  • Boot python examples/hello_mock_seller.py — MCP listening on http://0.0.0.0:3001/mcp without errors
  • Reviewer: confirm cache key shape (base_url, id(auth)) matches expected pooling semantics
  • Reviewer: confirm CONFIGURATION_ERROR allowlist entry is acceptable, or suggest spec-conformant alternative

🤖 Generated with Claude Code

…'mock_upstream_url'] + DecisioningPlatform.upstream_for (Phase 2)

Phase 2 of the lifecycle-state-and-sandbox-authority work. When a
resolved Account has mode='mock', the framework points the adapter's
UpstreamHttpClient at the mock-server fixture URL declared on the
account's metadata. Adapter code is unchanged across modes; only the
upstream URL changes per request.

The reframe vs. earlier proposal sketches: the adapter knows its own
production URL (GAM is always googleads.googleapis.com, Kevel is
always api.kevel.co). Per-tenant variation already flows through
ctx.auth_info (credentials) and ctx.account.metadata; mock-mode adds
one more variable — the upstream URL — when the adopter wants to
exercise their adapter against the spec's reference path
(bin/adcp.js mock-server <specialism>) without standing up a real
backend.

Surface:

- DecisioningPlatform.upstream_url: ClassVar[str | None] — adopter
  declares the production URL on their subclass. None is allowed for
  pure in-process platforms.
- DecisioningPlatform.upstream_for(ctx, *, auth, default_headers,
  timeout, treat_404_as_none) -> UpstreamHttpClient — picks the
  right URL based on ctx.account.mode, caches by (base_url, id(auth))
  per platform instance, fail-closes with CONFIGURATION_ERROR when
  mock-mode is missing metadata['mock_upstream_url'] or live/sandbox
  mode lacks upstream_url.
- get_mock_upstream_url(account) — type-safe accessor that handles
  dict, non-mapping, missing, empty, and non-string cases.

Mock-server lifecycle is NOT managed by the SDK. Adopters or CI start
bin/adcp.js mock-server <specialism> and populate the URL on the
account in their AccountStore.resolve.

Tests cover happy paths (live, sandbox, two distinct mock URLs),
fail-closed (missing/empty/non-string mock URL, missing platform
upstream_url for live/sandbox), cache identity (same auth → same
client; different auth → different client; per-platform-instance
isolation), and auth threading.

Phase 1 (PR #483) shipped Account.mode + the comply-controller gate;
Phase 2 wires the routing. Phase 3 (composition / opt-out for adopters
with bespoke sandbox needs) is deferred.

References:
- JS umbrella: adcontextprotocol/adcp-client#1435
- Phase 1: #483
- Proposal: docs/proposals/lifecycle-state-and-sandbox-authority.md
  (note: proposal envisioned out-of-process forwarding; the actual
  mock-server is a per-specialism upstream-API REST fixture, so the
  routing is a base-URL swap rather than a transport forwarder)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley force-pushed the bokelley/feat-mock-mode-dispatch branch from b1c3d42 to 737cc2e Compare May 3, 2026 16:57
@bokelley bokelley merged commit 5c605b4 into main May 3, 2026
14 checks passed
bokelley added a commit that referenced this pull request May 3, 2026
…de (Phase 3) (#488)

* refactor(examples/v3-reference-seller): adopt upstream_for + Account.mode='mock' pattern (Phase 3 of #1435 port)

Migrates the v3 reference seller off its bespoke httpx client and onto
the SDK's UpstreamHttpClient + DecisioningPlatform.upstream_for routing
shipped in Phase 2 (#487). The reference seller is the canonical
adopter migration template — what salesagent and other translator-
pattern adopters mirror.

Concrete changes:

* src/upstream.py — replaces the bespoke MockUpstreamClient
  (httpx.AsyncClient + UpstreamError + per-call _request) with
  module-level domain helpers (list_products, create_order, etc.)
  taking a pooled adcp.decisioning.UpstreamHttpClient. Auth + 4xx/5xx
  → AdcpError projection now flow through the SDK client; the helpers
  shape only the upstream-specific paths/payloads.

* src/platform.py — declares upstream_url class attribute (production-
  shape placeholder adopters replace when migrating to mode='live').
  Method bodies resolve the client via self.upstream_for(ctx,
  auth=self._upstream_auth) instead of holding a constructed client.
  Constructor takes upstream_api_key (wired into a single StaticBearer
  shared across upstream_for() calls so the framework caches one
  pooled httpx.AsyncClient per base URL). Drops the bespoke
  _translate_upstream helper — the SDK's UpstreamHttpClient already
  projects every status code we cared about, with not_found_code
  override per call where the right AdCP code wasn't MEDIA_BUY_NOT_FOUND.

* src/platform.py::_make_account_store — every resolved account is
  stamped mode='mock' + metadata['mock_upstream_url'] (Phase 1 +
  Phase 2 wiring). Adopters with a real production upstream branch on
  row lifecycle to set mode='live' for production accounts and
  reserve mode='mock' for conformance / storyboard accounts only.

* src/app.py — drops MockUpstreamClient construction; passes
  upstream_api_key + mock_upstream_url (sourced from the existing
  MOCK_AD_SERVER_URL / MOCK_AD_SERVER_API_KEY env vars — env contract
  preserved, only the wiring changes). Storyboard CI behavior is
  unchanged.

* tests/ — every translator-pattern test updated to construct
  Account(mode='mock', metadata={'mock_upstream_url': ...}) and let
  the framework's upstream_for() route. Removed the four
  _translate_upstream unit tests (the helper is gone); end-to-end
  status-code mapping coverage stays via the existing respx-driven
  tests (401→AUTH_REQUIRED, 404→ACCOUNT_NOT_FOUND, 429→RATE_LIMITED,
  500→SERVICE_UNAVAILABLE). Added tests asserting the AccountStore
  stamps mode='mock' + mock_upstream_url and that the platform
  declares upstream_url.

* MIGRATION.md / README.md — updated to walk adopters through the new
  pattern. Adopters declaring upstream_url + branching account.mode
  in their AccountStore.resolve get the same code path against live
  and mock without per-call branching.

Quality gates green: ruff clean, mypy unchanged from baseline (17
pre-existing errors, none introduced), pytest examples (38 passed),
pytest tests (3616 passed). Storyboard CI needs no workflow changes —
the MOCK_AD_SERVER_URL / MOCK_AD_SERVER_API_KEY env contract is
preserved.

Refs Phase 1 (#483), Phase 2 (#487).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(examples/v3-reference-seller): MIGRATION.md recovery row, mock-url loader fail-fast, Phase 2→3 delta

Address review-pack on PR #488:

* MIGRATION.md spec-error-codes table: 400 → INVALID_REQUEST projects
  to ``recovery='retry_with_changes'`` per the SDK's
  ``UpstreamHttpClient._project_status``, not ``terminal``. Add a note
  clarifying the table reflects SDK defaults and adopters override
  per-call.

* ``_make_account_store`` fails-fast with ``CONFIGURATION_ERROR`` when
  ``mock_upstream_url`` is ``None`` and the loader is invoked. Drops
  the ``mock-upstream-not-configured.invalid`` placeholder that would
  otherwise cascade into an unprojected ``httpx.ConnectError``. Tests
  that bypass the AccountStore (constructing ``Account`` objects
  directly) keep working — the constructor still accepts
  ``mock_upstream_url=None``, the loader just refuses to resolve.

* MIGRATION.md gains a "Migrating from a bespoke httpx upstream client
  (Phase 2 → Phase 3)" section with grep-replace before/after blocks
  for: class field, per-method usage, account wiring, error handling,
  and constructor-arg test updates.

Nice-to-have: drop the misleading ``# Forward optional filtering
hints`` comment in ``get_products`` (the call passes only
``network_code``); annotate the ``handoff._fn`` private-attr access
with ``# noqa: SLF001`` and a one-line explanation pointing to the
absent public driver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant