Skip to content

feat(v3-ref-seller): translator pattern with JS mock-server upstream#447

Merged
bokelley merged 4 commits intomainfrom
bokelley/v3-translator-pattern
May 3, 2026
Merged

feat(v3-ref-seller): translator pattern with JS mock-server upstream#447
bokelley merged 4 commits intomainfrom
bokelley/v3-translator-pattern

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented May 3, 2026

Summary

The v3 reference seller stops being an in-process AdCP seller that
duplicates upstream persistence and becomes a translator: AdCP
wire on the inside, the JS mock-server (@adcp/client sales-guaranteed, GAM-flavored) over HTTP on the outside.

The architectural change matters because real adopters — primarily
Prebid's salesagent, GAM-
fronting middleware, FreeWheel-fronting middleware, in-house seller
adapters — already have an ad server. The reference seller should
demonstrate the translator seam, not duplicate the upstream's
persistence inside the AdCP wire layer.

┌──────────────┐        ┌─────────────────────────┐        ┌────────────────┐
│  AdCP buyer  │  MCP/  │  v3 reference seller    │  HTTP  │  JS mock-server │
│   (signed/   │  A2A   │  (this PR)              │  ────► │  (sales-        │
│   bearer)    │ ─────► │                         │        │   guaranteed)   │
└──────────────┘        │  • AdCP wire validation │        └────────────────┘
                        │  • Tier 2 identity gate │              ▲
                        │  • Account translation  │              │
                        │  • Postgres for IDs &   │              │
                        │    commercial relation  │              │
                        └─────────────────────────┘              │
                                  ▲                              │
                                  │                              │
                            ┌─────┴──────┐                       │
                            │ Postgres   │                       │
                            │ (tenants,  │                       │
                            │  agents,   │                       │
                            │  accounts) │                       │
                            └────────────┘                       │
                                                                 │
                              network_code + advertiser_id ──────┘

The local Postgres carries only the commercial-identity layer. Ad-ops
state — orders / line items / creatives / delivery — lives upstream.
Each Account.ext carries {network_code, advertiser_id} so the
translator can route requests to the right upstream tenant.

Key changes

  • New src/upstream.py — httpx-based MockUpstreamClient
    mirroring the JS mock's openapi.yaml 1:1 (products, inventory,
    forecast, availability, orders, lineitems, creatives, delivery,
    conversions/CAPI, tasks).
  • Models drop MediaBuy / Creative / PerformanceFeedback
    ad-ops state lives upstream. Account.ext carries the translation
    routing.
  • Platform rewires every ad-ops method to call the upstream over
    HTTP. create_media_buy returns a TaskHandoff for the upstream's
    pending_approval path; the framework surfaces a Submitted
    envelope to the buyer and a background coroutine polls
    /v1/tasks/{id} until the upstream auto-approves.
    update_media_buy raises UNSUPPORTED_FEATURE (the mock has no
    order-update endpoint; real adopters wire their PATCH flow).
    sync_accounts / list_accounts stay local Postgres — that mapping
    is the durable record this seller owns.
  • Capabilities claim BOTH sales-non-guaranteed AND
    sales-guaranteed
    — the mock supports both delivery_types, and
    real GAM-shaped publishers sell both surfaces.
  • Tests use respx to mock httpx so the Python pytest CI run
    doesn't need to boot Node. New CI jobs:
    v3-reference-seller-tests (pytest, no JS) and
    storyboard-v3-reference-seller (boots the JS mock + Python seller
    for the real storyboard runner).
  • New MIGRATION.md for maintainers of pre-v3 sales agents
    (Prebid's salesagent as the primary use case): fork this directory,
    replace MockUpstreamClient with your real ad-server client,
    reseed Account.ext, deploy.

Migration use case (Prebid salesagent)

The translator pattern is what an existing Prebid salesagent operator
needs. They already have an ad-ops integration (orders / line items /
delivery against their backing system); they need the AdCP wire layer
and the Tier 2 commercial-identity gate without rewriting their
ad-ops code. The new MIGRATION.md walks them through:

  1. Fork examples/v3_reference_seller as their starting point.
  2. Replace MockUpstreamClient with their existing upstream client.
  3. Reseed BuyerAgent / Account tables with their tenant config.
  4. Deploy.

Their existing upstream code is preserved verbatim. Only the AdCP
wire layer comes from the SDK.

Test plan

  • pytest examples/v3_reference_seller/tests/ -v — 23 tests pass
    (Protocol surface, projection guards, every translator method
    asserts the right upstream HTTP call via respx).
  • ruff check src/ examples/v3_reference_seller/ — clean.
  • mypy src/adcp/ — 747 source files, no issues.
  • pytest tests/ — full main test suite still passes
    (3204 passed).
  • CI: v3-reference-seller-tests runs the respx-mocked tests.
  • CI: storyboard-v3-reference-seller boots the real JS mock-
    server + Python seller and runs the canonical storyboard runner
    (non-blocking initially per the existing storyboard job pattern).

🤖 Generated with Claude Code

bokelley added a commit that referenced this pull request May 3, 2026
…g (PR #447 fix-pack)

Replace non-spec error codes (INTERNAL_ERROR, AUTH_INVALID) with
canonical ErrorCode enum values so wire responses survive strict
validation. Map upstream HTTP 400 to terminal INVALID_REQUEST
instead of falling through to the default code. Tighten CAPI perf
feedback to conversion_rate only (the only AdCP metric_type with
even loose CAPI semantics) and document the gap in MIGRATION.md.

- AdcpError("INTERNAL_ERROR", ...) → SERVICE_UNAVAILABLE / transient
  for upstream transient failures and onboarding misconfig
- AdcpError("AUTH_INVALID", ...) → AUTH_REQUIRED / terminal for
  missing or rejected bearer / X-Network-Code
- _translate_upstream: add explicit 400 → INVALID_REQUEST branch,
  add 429 → RATE_LIMITED branch, fix recovery="retry" (not in spec)
  to recovery="transient" on the default-code fall-through
- provide_performance_feedback: gate to metric_type='conversion_rate'
  only; raise INVALID_REQUEST with field='metric_type' otherwise
- get_products: filter out non-cpm pricing rows (seller declares
  pricing_models=('cpm',); skip-and-log rather than projecting)
- _project_creative_status: paused → pending_review (was approved)
- create_media_buy poll: jitter 0.5–1.5× the base interval
- update_media_buy: clarify GAM LineItemService.performLineItemAction
  is the production wiring point
- MIGRATION.md: document spec error codes (canonical enum vs legacy
  SDK codes), CAPI semantic mismatch, and "what this seller doesn't
  yet support upstream" (update_media_buy)
- Tests: cover _translate_upstream (400/401/429/500), conversion_rate
  gate, account-loader SERVICE_UNAVAILABLE projection

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley added a commit that referenced this pull request May 3, 2026
…tion + MIGRATION (PR #447 fix-pack 2)

* create_media_buy polling no longer fabricates success on:
  - loop exhaustion (now raises SERVICE_UNAVAILABLE/transient)
  - upstream rejected status (now raises PERMISSION_DENIED/terminal)
  - missing approval_task_id with non-terminal status (now refetches once
    and projects from actual current status, never enters polling loop)
* _translate_upstream gains per-callsite not_found_code; get_products
  and list_creatives 404s now surface ACCOUNT_NOT_FOUND instead of
  the misleading MEDIA_BUY_NOT_FOUND.
* get_media_buy_delivery double-fetches the order so AdCP MediaBuyStatus
  reflects upstream state (DeliveryReport doesn't carry status, so
  completed/canceled/rejected buys would have surfaced as 'active').
* list_accounts now sets pagination.total_count.
* MIGRATION.md adds a Pre-v3 to v3 mapping table for Prebid salesagent
  porting, plus specialism-declaration upgrade, strict-validation
  gotchas, and spec-error-code reference sections.
* CI readiness probe uses /_debug/traffic (non-network-scoped, no auth)
  so seed-data renames don't break the boot. Adds a post-seller-boot
  upstream-alive probe that fails fast if the seller crashed the upstream.
* Tests: polling timeout, polling rejection, no-task refetch path,
  ACCOUNT_NOT_FOUND callsite override, 401/500/429/malformed-JSON
  failure paths, completed/canceled status projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley closed this May 3, 2026
@bokelley bokelley reopened this May 3, 2026
bokelley and others added 3 commits May 3, 2026 01:29
…op in-process ad-ops persistence

The v3 reference seller stops being an in-process AdCP seller that
duplicates upstream persistence and becomes a translator: AdCP wire
on the inside, the JS mock-server (@adcp/client sales-guaranteed) on
the outside. Real adopters (Prebid salesagent, GAM-shaped publishers,
etc.) have an existing ad server; the reference should demonstrate the
translator seam — not the inverse "build an ad server inside your
seller" pattern.

Changes:

* New src/upstream.py — httpx-based MockUpstreamClient mirroring the
  JS mock's openapi.yaml (products, orders, lineitems, creatives,
  delivery, conversions, tasks, forecast).
* models.py drops MediaBuy / Creative / PerformanceFeedback. Account
  carries upstream routing (network_code, advertiser_id) on the ext
  JSON column.
* platform.py rewires every ad-ops method to call upstream over HTTP
  and translate to AdCP wire shapes. create_media_buy returns a
  TaskHandoff for the upstream's pending_approval path; the framework
  surfaces a Submitted envelope to the buyer and runs a background
  poll until the upstream auto-approves. update_media_buy raises
  UNSUPPORTED_FEATURE (the mock has no order-update endpoint).
  sync_accounts / list_accounts stay local Postgres — the AdCP-account
  to upstream-network mapping is the durable record this seller owns.
* Capabilities now claim BOTH sales-non-guaranteed AND sales-guaranteed.
  The mock supports delivery_type: guaranteed/non_guaranteed; real
  GAM-shaped publishers sell both surfaces.
* Tests use respx to mock httpx so the Python pytest CI run doesn't
  need to boot Node. New CI jobs: v3-reference-seller-tests (pytest)
  and storyboard-v3-reference-seller (boots the JS mock + Python
  seller for the real storyboard runner).
* New MIGRATION.md targeted at maintainers of pre-v3 sales agents
  (Prebid salesagent specifically) — fork this directory, replace
  MockUpstreamClient with your real ad-server client, reseed
  Account.ext, deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…g (PR #447 fix-pack)

Replace non-spec error codes (INTERNAL_ERROR, AUTH_INVALID) with
canonical ErrorCode enum values so wire responses survive strict
validation. Map upstream HTTP 400 to terminal INVALID_REQUEST
instead of falling through to the default code. Tighten CAPI perf
feedback to conversion_rate only (the only AdCP metric_type with
even loose CAPI semantics) and document the gap in MIGRATION.md.

- AdcpError("INTERNAL_ERROR", ...) → SERVICE_UNAVAILABLE / transient
  for upstream transient failures and onboarding misconfig
- AdcpError("AUTH_INVALID", ...) → AUTH_REQUIRED / terminal for
  missing or rejected bearer / X-Network-Code
- _translate_upstream: add explicit 400 → INVALID_REQUEST branch,
  add 429 → RATE_LIMITED branch, fix recovery="retry" (not in spec)
  to recovery="transient" on the default-code fall-through
- provide_performance_feedback: gate to metric_type='conversion_rate'
  only; raise INVALID_REQUEST with field='metric_type' otherwise
- get_products: filter out non-cpm pricing rows (seller declares
  pricing_models=('cpm',); skip-and-log rather than projecting)
- _project_creative_status: paused → pending_review (was approved)
- create_media_buy poll: jitter 0.5–1.5× the base interval
- update_media_buy: clarify GAM LineItemService.performLineItemAction
  is the production wiring point
- MIGRATION.md: document spec error codes (canonical enum vs legacy
  SDK codes), CAPI semantic mismatch, and "what this seller doesn't
  yet support upstream" (update_media_buy)
- Tests: cover _translate_upstream (400/401/429/500), conversion_rate
  gate, account-loader SERVICE_UNAVAILABLE projection

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion + MIGRATION (PR #447 fix-pack 2)

* create_media_buy polling no longer fabricates success on:
  - loop exhaustion (now raises SERVICE_UNAVAILABLE/transient)
  - upstream rejected status (now raises PERMISSION_DENIED/terminal)
  - missing approval_task_id with non-terminal status (now refetches once
    and projects from actual current status, never enters polling loop)
* _translate_upstream gains per-callsite not_found_code; get_products
  and list_creatives 404s now surface ACCOUNT_NOT_FOUND instead of
  the misleading MEDIA_BUY_NOT_FOUND.
* get_media_buy_delivery double-fetches the order so AdCP MediaBuyStatus
  reflects upstream state (DeliveryReport doesn't carry status, so
  completed/canceled/rejected buys would have surfaced as 'active').
* list_accounts now sets pagination.total_count.
* MIGRATION.md adds a Pre-v3 to v3 mapping table for Prebid salesagent
  porting, plus specialism-declaration upgrade, strict-validation
  gotchas, and spec-error-code reference sections.
* CI readiness probe uses /_debug/traffic (non-network-scoped, no auth)
  so seed-data renames don't break the boot. Adds a post-seller-boot
  upstream-alive probe that fails fast if the seller crashed the upstream.
* Tests: polling timeout, polling rejection, no-task refetch path,
  ACCOUNT_NOT_FOUND callsite override, 401/500/429/malformed-JSON
  failure paths, completed/canceled status projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley force-pushed the bokelley/v3-translator-pattern branch from 4d90268 to 22aa4fe Compare May 3, 2026 05:31
The new pytest job for the translator pattern was failing all tests
with ModuleNotFoundError: 'sqlalchemy'. The example imports sqlalchemy
+ asyncpg + respx but the SDK's [dev] extras don't carry them.

Install inline rather than adding a one-off optional-dependencies group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 4185ced into main May 3, 2026
12 of 14 checks passed
bokelley added a commit that referenced this pull request May 3, 2026
Bring the branch up to date with main (PRs #441, #444, #445, #446, #447).
Conflicts resolved:

* ``.github/workflows/ci.yml`` — keep main's restructured v3 storyboard
  job (translator-pattern upstream mock + readiness loop) and apply
  the poll-fix pattern (``||`` outside command substitution) to both
  the upstream-mock readiness loop and main's new seller-readiness
  loop.
* ``src/adcp/server/serve.py`` — combine main's strict-by-default
  ``validation=DEFAULT_VALIDATION`` flip with the three transport-
  security kwargs added on this branch (``allowed_hosts``,
  ``allowed_origins``, ``enable_dns_rebinding_protection``).

Auto-merged: ``examples/v3_reference_seller/src/app.py`` (translator
rewrite + this branch's webhook/dispose/DNS-rebinding kwargs),
``examples/v3_reference_seller/tests/test_smoke.py`` (translator
rewrite + this branch's port-stripping regression test),
``src/adcp/server/mcp_tools.py`` (lazy schema gen + this branch's
``setdefault("type", "object")`` for union outputSchemas),
``examples/seller_agent.py`` (channels filter intact).
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