Skip to content

refactor(examples): v3 reference seller adopts upstream_for + mock-mode (Phase 3)#488

Merged
bokelley merged 2 commits into
mainfrom
bokelley/feat-ref-seller-upstream-for
May 3, 2026
Merged

refactor(examples): v3 reference seller adopts upstream_for + mock-mode (Phase 3)#488
bokelley merged 2 commits into
mainfrom
bokelley/feat-ref-seller-upstream-for

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 3, 2026

Summary

Phase 3 of the lifecycle-state-and-sandbox-authority work: refactor the v3 reference seller to use the SDK's UpstreamHttpClient + DecisioningPlatform.upstream_for mock-mode routing shipped in Phase 2 (#487). The reference seller is the canonical adopter migration template — what salesagent and other translator-pattern adopters mirror when they migrate.

  • Bespoke httpx client gone. MockUpstreamClient (httpx pooling + ApiKey auth + bespoke UpstreamError projection) replaced with module-level domain helpers (list_products, create_order, get_delivery, ...) taking the SDK's UpstreamHttpClient. ~285 LoC of HTTP boilerplate gone, ~270 LoC of focused upstream-shape helpers stay.
  • upstream_url + upstream_for(ctx) wiring. The platform declares upstream_url = "https://sales-guaranteed.example.invalid/v1" (placeholder adopters replace with their production URL). Each method body resolves the pooled client via self.upstream_for(ctx, auth=self._upstream_auth) — the framework picks upstream_url for mode='live'/'sandbox' and account.metadata['mock_upstream_url'] for mode='mock'. Same adapter code path against either URL.
  • AccountStore stamps mode='mock'. Every resolved account is mode='mock' + metadata['mock_upstream_url'] (sourced from the existing MOCK_AD_SERVER_URL env). 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.
  • Auth: StaticBearer. Authorization: Bearer <api_key> flows through the SDK's StaticBearer; per-call X-Network-Code flows as a per-call header on each upstream-helper invocation. Single StaticBearer instance shared across upstream_for() calls means the framework's client cache pools one httpx.AsyncClient per base URL.
  • Error handling: SDK projection. Bespoke _translate_upstream helper deleted — the SDK's UpstreamHttpClient already projects every status code we cared about (401 → AUTH_REQUIRED, 403 → PERMISSION_DENIED, 404 → not_found_code (configurable per-call), 429 → RATE_LIMITED, 5xx → SERVICE_UNAVAILABLE, other 4xx → INVALID_REQUEST). Upstream helpers pass not_found_code='ACCOUNT_NOT_FOUND' for list_products / list_creatives so the right AdCP code surfaces.
  • Env contract preserved. MOCK_AD_SERVER_URL / MOCK_AD_SERVER_API_KEY still drive the wiring; storyboard CI needs no workflow changes.
  • MIGRATION.md / README.md updated to walk adopters through the new pattern (declare upstream_url, branch account.mode in AccountStore.resolve, use upstream_for in method bodies).

Test plan

  • ruff check examples/v3_reference_seller/ — clean
  • mypy examples/v3_reference_seller/src/ — same 17 pre-existing errors as before the refactor (none introduced by this change)
  • pytest examples/v3_reference_seller/tests/test_smoke.py examples/v3_reference_seller/tests/test_smoke_broadening.py — 38 passed
  • pytest tests/ (broad SDK regression) — 3616 passed, 31 skipped
  • End-to-end smoke via respx: a mode='mock' Account with metadata['mock_upstream_url']='http://up.smoke' correctly routes the platform's get_products at the fixture URL with Authorization: Bearer test-key + X-Network-Code: net_smoke headers intact
  • Live mock-server smoke — couldn't run locally (the npx -p @adcp/client adcp mock-server <specialism> sub-command isn't accessible via the published @adcp/client CLI from this checkout). Storyboard CI is the load-bearing regression gate and will catch any wire-level drift.

What this is for

This PR is the canonical adopter migration template. Salesagent maintainers (and other translator-pattern adopters — Prebid, GAM-fronting middleware, FreeWheel-fronting middleware) read this PR as the diff they mirror when they migrate to the Phase 2 mock-mode routing. The MIGRATION.md updates are the load-bearing documentation; the code is the reference.

Refs

🤖 Generated with Claude Code

bokelley and others added 2 commits May 3, 2026 13:54
…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>
…l 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>
@bokelley bokelley merged commit 59c177d into main May 3, 2026
14 checks passed
bokelley added a commit that referenced this pull request May 3, 2026
v1 shipped single-ProposalManager-per-serve(), wired through a kwarg on
serve()/PlatformHandler. Reviewer feedback (Brian, architect):
ProposalManager has to be per-tenant. Multi-tenant deployments
(salesagent, agentic-adapters social) need different proposal logic per
tenant — a GAM tenant has different products from a Kevel tenant; a Meta
tenant has different proposal assembly from a TikTok tenant.
Single-tenant binding doesn't fit.

Changes:

- PlatformRouter accepts ``proposal_managers={tenant_id:
  ProposalManager}``. Validates keys are a subset of platforms keys
  at construction (orphan tenants raise ValueError). The router
  overrides its synthesized ``get_products`` delegation with an
  explicit method that does per-tenant manager lookup, refine-mode
  selection (capability + method-presence gated), and per-tenant
  fall-through to ``platforms[tenant_id].get_products`` when no
  manager is wired — back-compat per tenant.

- ``serve(proposal_manager=)`` and
  ``create_adcp_server_from_platform(proposal_manager=)`` kwargs
  removed. Single-tenant adopters wire a one-entry router:
  ``PlatformRouter(platforms={"default": ...},
  proposal_managers={"default": ...})``. Same pattern as v3 reference
  seller adopted in PR #488.

- PlatformHandler's ``proposal_manager=`` field and
  ``_select_proposal_method`` helper removed. The router's
  ``get_products`` does its own dispatch and the handler delegates
  uniformly via ``_invoke_platform_method``.

- ``examples/hello_proposal_manager.py`` rewritten to demonstrate the
  per-tenant binding: tenant_acme has a wired MockProposalManager;
  tenant_globex falls through to its platform's get_products.

- Tests rewritten to cover per-tenant routing: orphan-key validation,
  per-tenant isolation, per-tenant fall-through, sync+async manager
  dispatch, refine routing across all four conditions. 18 tests
  total, all green; full regression suite (3720 tests) green; no
  existing example modified (v3 reference seller, hello_seller,
  hello_mock_seller, multi_platform_seller — back-compat preserved
  since none ever used the removed ``proposal_manager=`` kwarg).

- docs/proposals/product-architecture.md § "Open questions" updated:
  the tenant binding model question is now resolved (v1 ships
  per-tenant via PlatformRouter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 3, 2026
v1 shipped single-ProposalManager-per-serve(), wired through a kwarg on
serve()/PlatformHandler. Reviewer feedback (Brian, architect):
ProposalManager has to be per-tenant. Multi-tenant deployments
(salesagent, agentic-adapters social) need different proposal logic per
tenant — a GAM tenant has different products from a Kevel tenant; a Meta
tenant has different proposal assembly from a TikTok tenant.
Single-tenant binding doesn't fit.

Changes:

- PlatformRouter accepts ``proposal_managers={tenant_id:
  ProposalManager}``. Validates keys are a subset of platforms keys
  at construction (orphan tenants raise ValueError). The router
  overrides its synthesized ``get_products`` delegation with an
  explicit method that does per-tenant manager lookup, refine-mode
  selection (capability + method-presence gated), and per-tenant
  fall-through to ``platforms[tenant_id].get_products`` when no
  manager is wired — back-compat per tenant.

- ``serve(proposal_manager=)`` and
  ``create_adcp_server_from_platform(proposal_manager=)`` kwargs
  removed. Single-tenant adopters wire a one-entry router:
  ``PlatformRouter(platforms={"default": ...},
  proposal_managers={"default": ...})``. Same pattern as v3 reference
  seller adopted in PR #488.

- PlatformHandler's ``proposal_manager=`` field and
  ``_select_proposal_method`` helper removed. The router's
  ``get_products`` does its own dispatch and the handler delegates
  uniformly via ``_invoke_platform_method``.

- ``examples/hello_proposal_manager.py`` rewritten to demonstrate the
  per-tenant binding: tenant_acme has a wired MockProposalManager;
  tenant_globex falls through to its platform's get_products.

- Tests rewritten to cover per-tenant routing: orphan-key validation,
  per-tenant isolation, per-tenant fall-through, sync+async manager
  dispatch, refine routing across all four conditions. 18 tests
  total, all green; full regression suite (3720 tests) green; no
  existing example modified (v3 reference seller, hello_seller,
  hello_mock_seller, multi_platform_seller — back-compat preserved
  since none ever used the removed ``proposal_manager=`` kwarg).

- docs/proposals/product-architecture.md § "Open questions" updated:
  the tenant binding model question is now resolved (v1 ships
  per-tenant via PlatformRouter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 3, 2026
v1 shipped single-ProposalManager-per-serve(), wired through a kwarg on
serve()/PlatformHandler. Reviewer feedback (Brian, architect):
ProposalManager has to be per-tenant. Multi-tenant deployments
(salesagent, agentic-adapters social) need different proposal logic per
tenant — a GAM tenant has different products from a Kevel tenant; a Meta
tenant has different proposal assembly from a TikTok tenant.
Single-tenant binding doesn't fit.

Changes:

- PlatformRouter accepts ``proposal_managers={tenant_id:
  ProposalManager}``. Validates keys are a subset of platforms keys
  at construction (orphan tenants raise ValueError). The router
  overrides its synthesized ``get_products`` delegation with an
  explicit method that does per-tenant manager lookup, refine-mode
  selection (capability + method-presence gated), and per-tenant
  fall-through to ``platforms[tenant_id].get_products`` when no
  manager is wired — back-compat per tenant.

- ``serve(proposal_manager=)`` and
  ``create_adcp_server_from_platform(proposal_manager=)`` kwargs
  removed. Single-tenant adopters wire a one-entry router:
  ``PlatformRouter(platforms={"default": ...},
  proposal_managers={"default": ...})``. Same pattern as v3 reference
  seller adopted in PR #488.

- PlatformHandler's ``proposal_manager=`` field and
  ``_select_proposal_method`` helper removed. The router's
  ``get_products`` does its own dispatch and the handler delegates
  uniformly via ``_invoke_platform_method``.

- ``examples/hello_proposal_manager.py`` rewritten to demonstrate the
  per-tenant binding: tenant_acme has a wired MockProposalManager;
  tenant_globex falls through to its platform's get_products.

- Tests rewritten to cover per-tenant routing: orphan-key validation,
  per-tenant isolation, per-tenant fall-through, sync+async manager
  dispatch, refine routing across all four conditions. 18 tests
  total, all green; full regression suite (3720 tests) green; no
  existing example modified (v3 reference seller, hello_seller,
  hello_mock_seller, multi_platform_seller — back-compat preserved
  since none ever used the removed ``proposal_manager=`` kwarg).

- docs/proposals/product-architecture.md § "Open questions" updated:
  the tenant binding model question is now resolved (v1 ships
  per-tenant via PlatformRouter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 4, 2026
…r forwarder + tenant binding (#504)

* feat(decisioning): ProposalManager v1 — Protocol + MockProposalManager forwarder + tenant binding

Foundation for the two-platform composition established in the product
architecture design doc (PR #502, docs/proposals/product-architecture.md).
Adopters can now wire a separate ProposalManager that handles
get_products / refine, while DecisioningPlatform handles create_media_buy
and lifecycle. The recipe (typed implementation_config) is the contract
between them.

v1 surface:

- ProposalManager Protocol (sync/async, capability-gated refine)
- ProposalCapabilities dataclass (sales_specialism + flags)
- Recipe Pydantic base with recipe_kind discriminator
- MockProposalManager v1 default forwarder (symmetric with Phase 2's
  upstream_for mock-mode dispatch on DecisioningPlatform)
- proposal_manager= kwarg on serve() / create_adcp_server_from_platform
- Dispatcher routing in PlatformHandler.get_products: routes to
  ProposalManager when wired (with refine/get_products selection by
  buying_mode + capability + method-presence); falls through to
  platform.get_products otherwise — backward-compat by construction

Out of scope (deferred to subsequent PRs, called out in module doc):

- Session cache for in-flight proposals
- finalize transition (buying_mode='refine' + action='finalize')
- expires_at enforcement
- capability_overlap declaration on Recipe + framework validation
- Recipe persistence through buy lifecycle (hydration in
  create_media_buy / update_media_buy / get_delivery)
- Per-tenant ProposalManager binding via PlatformRouter
- MIGRATION.md updates for v3 reference seller

Tests: 16 new in tests/test_proposal_manager.py covering Protocol
conformance, capability validation, MockProposalManager forwarding
(respx-mocked), dispatcher routing with/without proposal_manager,
adopter subclass dispatch, sync+async support, and refine routing
across all four (capability, method, buying_mode, fall-through) cases.
Full regression suite (3718 tests) green.

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

* fix(decisioning): per-tenant ProposalManager binding via PlatformRouter

v1 shipped single-ProposalManager-per-serve(), wired through a kwarg on
serve()/PlatformHandler. Reviewer feedback (Brian, architect):
ProposalManager has to be per-tenant. Multi-tenant deployments
(salesagent, agentic-adapters social) need different proposal logic per
tenant — a GAM tenant has different products from a Kevel tenant; a Meta
tenant has different proposal assembly from a TikTok tenant.
Single-tenant binding doesn't fit.

Changes:

- PlatformRouter accepts ``proposal_managers={tenant_id:
  ProposalManager}``. Validates keys are a subset of platforms keys
  at construction (orphan tenants raise ValueError). The router
  overrides its synthesized ``get_products`` delegation with an
  explicit method that does per-tenant manager lookup, refine-mode
  selection (capability + method-presence gated), and per-tenant
  fall-through to ``platforms[tenant_id].get_products`` when no
  manager is wired — back-compat per tenant.

- ``serve(proposal_manager=)`` and
  ``create_adcp_server_from_platform(proposal_manager=)`` kwargs
  removed. Single-tenant adopters wire a one-entry router:
  ``PlatformRouter(platforms={"default": ...},
  proposal_managers={"default": ...})``. Same pattern as v3 reference
  seller adopted in PR #488.

- PlatformHandler's ``proposal_manager=`` field and
  ``_select_proposal_method`` helper removed. The router's
  ``get_products`` does its own dispatch and the handler delegates
  uniformly via ``_invoke_platform_method``.

- ``examples/hello_proposal_manager.py`` rewritten to demonstrate the
  per-tenant binding: tenant_acme has a wired MockProposalManager;
  tenant_globex falls through to its platform's get_products.

- Tests rewritten to cover per-tenant routing: orphan-key validation,
  per-tenant isolation, per-tenant fall-through, sync+async manager
  dispatch, refine routing across all four conditions. 18 tests
  total, all green; full regression suite (3720 tests) green; no
  existing example modified (v3 reference seller, hello_seller,
  hello_mock_seller, multi_platform_seller — back-compat preserved
  since none ever used the removed ``proposal_manager=`` kwarg).

- docs/proposals/product-architecture.md § "Open questions" updated:
  the tenant binding model question is now resolved (v1 ships
  per-tenant via PlatformRouter).

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