refactor(examples): v3 reference seller adopts upstream_for + mock-mode (Phase 3)#488
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 3 of the lifecycle-state-and-sandbox-authority work: refactor the v3 reference seller to use the SDK's
UpstreamHttpClient+DecisioningPlatform.upstream_formock-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.MockUpstreamClient(httpx pooling + ApiKey auth + bespokeUpstreamErrorprojection) replaced with module-level domain helpers (list_products,create_order,get_delivery, ...) taking the SDK'sUpstreamHttpClient. ~285 LoC of HTTP boilerplate gone, ~270 LoC of focused upstream-shape helpers stay.upstream_url+upstream_for(ctx)wiring. The platform declaresupstream_url = "https://sales-guaranteed.example.invalid/v1"(placeholder adopters replace with their production URL). Each method body resolves the pooled client viaself.upstream_for(ctx, auth=self._upstream_auth)— the framework picksupstream_urlformode='live'/'sandbox'andaccount.metadata['mock_upstream_url']formode='mock'. Same adapter code path against either URL.mode='mock'. Every resolved account ismode='mock'+metadata['mock_upstream_url'](sourced from the existingMOCK_AD_SERVER_URLenv). Adopters with a real production upstream branch on row lifecycle to setmode='live'for production accounts and reservemode='mock'for conformance / storyboard accounts only.StaticBearer.Authorization: Bearer <api_key>flows through the SDK'sStaticBearer; per-callX-Network-Codeflows as a per-call header on each upstream-helper invocation. SingleStaticBearerinstance shared acrossupstream_for()calls means the framework's client cache pools onehttpx.AsyncClientper base URL._translate_upstreamhelper deleted — the SDK'sUpstreamHttpClientalready 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 passnot_found_code='ACCOUNT_NOT_FOUND'forlist_products/list_creativesso the right AdCP code surfaces.MOCK_AD_SERVER_URL/MOCK_AD_SERVER_API_KEYstill drive the wiring; storyboard CI needs no workflow changes.upstream_url, branchaccount.modeinAccountStore.resolve, useupstream_forin method bodies).Test plan
ruff check examples/v3_reference_seller/— cleanmypy 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 passedpytest tests/(broad SDK regression) — 3616 passed, 31 skippedmode='mock'Account withmetadata['mock_upstream_url']='http://up.smoke'correctly routes the platform'sget_productsat the fixture URL withAuthorization: Bearer test-key+X-Network-Code: net_smokeheaders intactnpx -p @adcp/client adcp mock-server <specialism>sub-command isn't accessible via the published@adcp/clientCLI 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