Feature/gam graceful order approval#14
Merged
Merged
Conversation
…responses (prebid#359) The ``media_buy_state_machine`` storyboard's ``pause_buy``/``resume_buy``/ ``cancel_buy`` steps assert ``field_present @ /status`` on the ``update_media_buy`` wire response. Buyers need the resulting status to confirm the lifecycle transition without an extra ``get_media_buys`` round-trip. Three response-construction sites were omitting ``status``: 1. The cancel path (``media_buy_update.py``) — set ``status="canceled"`` on the ``UpdateMediaBuySuccess``. 2. The pause/resume path — set ``status="paused"`` or ``status="active"`` based on the request's ``paused`` flag. 3. The manual-approval deferred path — surface the buy's CURRENT persisted status (the update hasn't transitioned the buy yet — it's pending human approval). Read ``current_buy.status`` directly rather than via ``_compute_status`` so the path is robust to mocked test fixtures whose ``start_time``/``end_time`` aren't real datetimes. Verified with the local storyboard run: * Before: ``state_transitions: passed=false`` — ``✗ Response includes updated status: Field not found at path: status`` * After: ``state_transitions: passed=true`` (pause + resume + cancel all green) The ``terminal_enforcement`` scenario still fails — it expects ``INVALID_STATE`` code on attempts to pause/resume/cancel a terminal buy. That's a separate spec gap (no ``AdCPInvalidStateError`` class yet) and out of scope for prebid#353. Three regression tests pin the new behavior: ``test_pause_response_includes_status_paused``, ``test_resume_response_includes_status_active``, ``test_cancel_response_includes_status_canceled``. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nc_accounts (prebid#360) ``SalesagentAccountStore._identity_from_ctx`` was reading tenant_id exclusively from the ``adcp.server.auth.current_tenant`` ContextVar, which ``BearerTokenAuthMiddleware`` sets but which doesn't propagate across the MCP stateful-session task boundary. Every list_accounts / sync_accounts call from an authenticated buyer landed with ``tenant_id=None`` and surfaced as ``ACCOUNT_NOT_FOUND`` / "no tenant resolved on the request context." The same store's ``resolve()`` path already had the fix: use :meth:`_tenant_from_principal` which falls back to ``auth_info.principal`` → DB lookup. Mirroring that chain inside ``_identity_from_ctx`` makes list/sync task-safe. Verified locally with ``adcp localmcp list_accounts --json`` (now returns ``accounts: []`` instead of crashing) and with the full ``pagination_integrity_list_accounts`` storyboard run (all three scenarios — capability_discovery, setup, pagination_walk — green). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rief (prebid#361) The ``media_buy_seller/proposal_finalize/get_products_brief`` storyboard asserts that ``get_products`` calls with ``buying_mode='brief'`` return a ``proposals[]`` array carrying at least one ``Proposal`` with a ``proposal_id`` buyers can echo into ``create_media_buy(proposal_id=...)`` to execute the bundle. Pre-PR the proposal manager forwarded directly to ``_get_products_impl`` and never emitted ``proposals``. v1 strategy: split budget evenly across every product the publisher returned. Each ``ProductAllocation`` references a real ``product_id`` and ``pricing_option_id`` from the response, percentages sum to exactly 100 (compensate for ``100/3`` non-termination on the final allocation rather than 99.99-rounded), and the proposal gets a fresh ``proposal_id`` per call. Only ``buying_mode='brief'`` triggers the proposal — wholesale and refine opt out per spec. Empty product list short-circuits to no proposal (the spec model requires ``min_length=1`` on allocations). Future allocation strategies (weighted, refine-loaded drafts) plug into the same ``_build_v1_brief_proposal`` seam without touching the manager. ## Verified * Storyboard ``media_buy_seller/proposal_finalize/get_products_brief``: PASS — every assertion green including ``field_present @ /proposals[0]/proposal_id``. * 10 new unit tests in ``test_proposal_manager_brief.py`` pin builder invariants (sum=100 across 1/2/3-product splits, unique proposal_id per call, RootModel unwrapping, optional pricing_option_id). * Full unit suite: 4295 passed. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ebhooks page (prebid#367) The "Manage Webhooks" button in templates/webhooks.html had two bugs: 1. The link used `{{ script_name }}` but the route that renders the page (`operations.py:710`) does not pass `script_name`. In embedded iframe context that template variable is undefined, so the link resolved to an unprefixed path and 404'd. 2. The destination is the per-tenant principals list, not a webhook management page — webhooks are per-principal. The label "Manage Webhooks" was misleading. Use `url_for()` so script-root resolution is automatic, and relabel the button to "Advertisers" with a users icon to match its actual target. The user reaches webhook management by clicking into an advertiser. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d#368) `AdapterConfig` is platform-managed on embedded tenants (only `gam_sandbox_advertiser_id` is in `PUBLISHER_WRITABLE_FIELDS`), and the `/settings/adapter` POST is intentionally not opted into `allow_embedded_writes`. The Targeting Criteria Browser still rendered the three "Set Include/Exclude/Macro Key" buttons + dropdowns + manual entry, so clicking them returned 403 and the toast read "Failed to save include key configuration." Hide the editor block on embedded tenants and replace it with a "Managed by platform" notice that points users at the upstream Tenant Management API. The targeting-key browsing/preview UI below the card stays visible — operators may want to look up keys when authoring products. Null-guard `populateAxeDropdowns` and `updateAxeKeyStatus` against the now-absent select/status elements so the page JS doesn't throw when the card body renders the alert variant. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e gate (prebid#372) Follow-up sweep to prebid#365. The embedded-write gate keys off HTTP verb, so every POST under `require_tenant_access` without `allow_embedded_writes=True` returns 403 `embedded_writes_not_permitted` on embedded tenants — even when the handler is a read-only probe that never touches the DB. prebid#365 fixed the two AI/Logfire probes called out in Laure's bug report. Sweep covers the rest of the same class: - `tenants.test_slack` — sends a test webhook, never writes - `adapters.test_freewheel_connection` — validates OAuth client_credentials against FreeWheel; reads AdapterConfig fallback secret, never writes - `adapters.test_triton_connection` — validates JWT login against Triton; reads AdapterConfig fallback secret, never writes - `adapters.test_broadstreet_connection` — validates API key against Broadstreet network endpoint, never writes - `settings.test_domain_access` — looks up tenant access for an email and flashes the result, never writes Each handler was inspected to confirm zero DB writes before adding the opt-in. The model-layer guard in `embedded_tenant_guard.py` remains in force as defense-in-depth — any accidental Tenant/AdapterConfig write from these paths would still be caught at commit time. Longer-term: the verb-based gate misclassifying probes is a design smell. A `probe=True` decorator argument that the gate honors would be more durable than per-route opt-in. Filing as a follow-up — out of scope for this sweep. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tenants (prebid#369) The "Test Connection" action for AI providers and Logfire on the Integrations tab failed with "Failed: embedded_writes_not_permitted" on embedded tenants, because the verb-based embedded-write gate classifies any POST under `require_tenant_access` as a mutation. `test_ai_connection` and `test_logfire_connection` are read-only probes — they validate credentials against the upstream provider and never write tenant state. Opt them into `allow_embedded_writes=True`; the model-layer guard in `embedded_tenant_guard.py` remains in force as defense-in-depth. Also fix the test-result handlers in `templates/tenant_settings.html` to render `data.message || data.error` instead of `data.error` alone. Gate envelopes (and any future role-gate rejections) return both a stable code in `error` and a human-readable string in `message`; the old code surfaced the stable code, which read as gibberish to users. Sweep finding (left as follow-up): the same verb-based-gate trap exists on `tenants.test_slack`, `adapters.test_freewheel_connection`, `adapters.test_triton_connection`, `adapters.test_broadstreet_connection`, and `settings.test_domain_access`. Each is a read-only probe that could opt in with the same flag. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nts (prebid#370) On embedded tenants every field in the Policies & Workflows tab (Brand Manifest Policy, Naming Conventions, Approval Workflows, Measurement Providers, Product Ranking, Auto-approval thresholds) silently reverted on save. Two compounding bugs: 1. Route blocked at the boundary. `/settings/business-rules` POST used `@require_tenant_access(role=("admin",))` without `allow_embedded_writes=True`, so the verb-based gate returned 403 `embedded_writes_not_permitted` before the handler ran. 2. JS treated the 403 HTML error page as success. `saveBusinessRules` in `tenant_settings.js` content-type-branched: any HTML response with no `.flash-messages` container fell through to `window.location.reload()`. Flask's default 403 error page has no flash messages → reload-as-success → user sees their fields revert with no error. Affected every 4xx/5xx on that route. Fix three layers: - Add `allow_embedded_writes=True` to `update_business_rules`. Per Sprint 5 design (`docs/design/embedded-mode-sprint-5.md` §"Pattern: shared business logic with the UI"), business rules are publisher-managed and edited via the proxied admin UI; the management API exposes the same writes. - Add the per-column business-rules surface to `PUBLISHER_WRITABLE_FIELDS[Tenant]` (13 fields covering naming templates, approval mode, creative review settings, AI policy, advertising policy, brand manifest policy, product ranking prompt, human review flag). Platform-identity columns (name, billing_plan, is_active, subdomain, external_*) stay locked. - Add `gam_manual_approval_required` / `mock_manual_approval_required` to `PUBLISHER_WRITABLE_FIELDS[AdapterConfig]` — these mirror `tenant.human_review_required` onto adapter config and are written by the same handler. - Restructure `saveBusinessRules` to check `response.ok` BEFORE content-type branching. Non-2xx responses now surface the error (parsing flash messages from HTML when available, falling back to the status code) instead of silently reloading. Added four guard tests in `test_managed_tenant_api.py::TestWriteGuard`: business-rules columns write, manual-approval adapter columns write, platform-identity columns stay blocked, and an end-to-end check via the mock adapter sync field. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d tenants (prebid#371) * fix(prebid#364): explain empty Allowed Principals dropdown on embedded tenants On embedded tenants the "Allowed Principals (Advertisers)" multi-select on Create Product rendered only "No principals configured" — a dead end. The Buyer Agents section in Settings hides the "Add Buyer Agent" button on embedded tenants (this is correct: Principal provisioning is platform-managed via the Tenant Management API), so publishers had no path to populate the dropdown. Two compounding things made the UI misleading: 1. The empty-state placeholder didn't distinguish embedded from open instances. Publishers saw the same "No principals configured" text that suggests they can fix it themselves. 2. Comments in `tenant_settings.html` and `buyer_advertiser_routing.py` claimed Principals are "auto-created on first request by the embedded-mode auth bypass, which reads X-Identity-Buyer-Principal-Id". That mechanism does not exist — grep `src/` for the header returns zero matches. Anyone tracing the empty dropdown ran into a dead-end comment that confidently pointed at a code path that isn't there. Fix: - In `add_product.html` and `add_product_gam.html`, replace the disabled `<option>No principals configured</option>` with a context-aware empty state. Embedded tenants get an explainer that Principals are provisioned by the platform via the Tenant Management API; open instances get a pointer to Settings → Buyer Agents. - Rewrite the misleading comment block in `tenant_settings.html` around the advertisers section and the user-visible "auto-created from request headers" line — state plainly that embedded Principal provisioning goes through the platform API. - Fix the matching dead-pointer comment in `buyer_advertiser_routing.py` near the access-grant logic. Option B (platform-managed) per `docs/design/embedded-mode-sprint-5.md` contract. Option A (re-enable UI authoring) would have been a write-guard expansion that contradicts the existing `{% if not embedded_view %}` gate on "Add Buyer Agent" — and the model guard doesn't list Principal at all, so it's the UI gate alone holding the line. Not the right place to flip the contract. Terminology cleanup ("Allowed Principals" vs "Buyer Agents" vs "Advertisers") is deliberately left for a follow-up issue — that's a larger UX project than a bug fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(prebid#364): update assertions to match corrected embedded-mode copy The original test asserted on the misleading "auto-created from request headers" copy that prebid#364 removed (because the auto-create mechanism does not exist — see prebid#364 PR description). Update the assertions to match the new, accurate copy that explains platform-API provisioning. Also refresh the class docstring to drop the same misleading claim about header-based auto-creation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nse boundary (prebid#375) The manual-approval path on ``update_media_buy`` read ``MediaBuy.status`` straight from the DB column and surfaced it on the ``UpdateMediaBuySuccess`` response. The persisted column accepts a broader set than the AdCP wire enum — ``draft`` (model default) and ``pending_approval`` (manual-approval create path) are both valid in storage but not in ``MediaBuyStatus``. fastmcp's request-/response-side Pydantic validation rejected the response with ``INVALID_REQUEST[status]: Input should be 'pending_creatives', 'pending_start', 'active', 'paused', 'completed', 'rejected' or 'canceled'``, which surfaced as an E2E failure on ``test_complete_campaign_lifecycle_with_webhooks`` (prebid#374) and on every PR's CI run after the manual-approval status-emission was added in prebid#353. Fix: - Add ``_to_wire_status`` in ``media_buy_list.py``. Takes any input (``str | MediaBuyStatus | None``) and returns either a wire-valid string from the seven-member enum, or ``None`` for values the wire rejects. Case-insensitive on string input. - Apply it at the manual-approval response site in ``update_media_buy.py``. ``current_status`` is now guaranteed wire-valid (or ``None``) before reaching ``UpdateMediaBuySuccess``. The other three response-status sites (cancel, pause/resume, final ``_compute_status`` path) already emit values from the wire enum by construction. Tests: - ``TestToWireStatus`` (6 cases): wire-valid passthrough, case insensitivity, persisted-only rejection (``draft``, ``pending_approval``), ``None``/empty/non-string handling. - ``test_manual_approval_response_coerces_non_wire_db_status_to_none``: end-to-end behavior — a persisted ``pending_approval`` does not leak to the response. - ``test_manual_approval_response_preserves_wire_valid_db_status``: wire-valid statuses still pass through unchanged. Verified locally: - Failing E2E ``test_complete_campaign_lifecycle_with_webhooks`` passes against the full Docker stack. - ``tox -e unit`` (4314 tests) and ``tox -e integration`` (1030 update_media_buy-adjacent tests) both green. Fixes prebid#374. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prebid#376) The MCP Python SDK's ``StreamableHTTPSessionManager`` stores ``_server_instances`` as a process-local dict. Multi-replica deployments without sticky LB routing on ``Mcp-Session-Id`` see ``tools/list`` and ``tools/call`` randomly 404 with "Session not found" when a request lands on a replica that didn't handle ``initialize``. A 10-attempt probe against the Wonderstruck deployment confirmed the dice roll: ``initialize`` always 200 (creates session on whichever replica answers); ``tools/list`` and ``tools/call`` with the same session ID succeeded only when they happened to land on the same replica (~50/50 each). Yesterday's compliance baseline (170 steps, 12 tools discovered) caught the deployment during a single-replica window; today the same baseline rerun returned 0 tools because ``discoverAgentProfile`` calls ``initialize`` → ``tools/list`` in tight succession, and ``tools/list`` lost the affinity coin flip half the time. ``serve()`` has supported ``stateless_http: bool`` since adcp 5.0 (``adcp/server/serve.py:2053`` sets ``mcp.settings.stateless_http`` from the kwarg unconditionally, so ``FASTMCP_STATELESS_HTTP`` env alone has no effect — the kwarg overrides FastMCP's reader). This plumbs the kwarg through ``_serve_kwargs`` gated on ``ADCP_STATELESS_HTTP``: * Unset / falsy → stateful (default). Single-replica prod, local dev, in-process tests, and the compliance-runner storyboard sweep keep the session-reuse perf optimization. * ``ADCP_STATELESS_HTTP=true`` → stateless. Each request creates a fresh transport context; multi-replica works without sticky LB. Per the FastMCP deployment doc (https://gofastmcp.com/v2/deployment/http): stateless mode is the recommended pattern for horizontal scaling — cookie-based stickiness is unreliable because most MCP clients use ``fetch()`` and drop ``Set-Cookie``. Header-based stickiness on ``Mcp-Session-Id`` would also work (the AdCP SDK forwards the header cleanly) and would keep session-reuse perf on prod compliance runs; this env var doesn't preclude that — the deployment chooses by setting / unsetting ``ADCP_STATELESS_HTTP``. Tests verify the env var maps to the kwarg correctly across true / false / unset and case variants. Existing ``test_serve_kwargs_middleware_order.py`` extended with the new ``stateless_http``-focused cases. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up: - SellerA2AClient for in-process A2A handler testing (prebid#694) - PgBuyerAgentRegistry.with_caching() factory (prebid#692) - v3 storyboard CI gate that actually asserts (prebid#693) - Sequence[T] widening on response-only list fields (prebid#635) - Composed lifespan preservation when public_url is callable (prebid#680) - ads.txt MANAGERDOMAIN fallback discovery (prebid#704/prebid#705) - validate_adagents_structure helper (prebid#708) - webhook_signing.supported boot validator (prebid#695) Audited the codebase for workarounds the bump should now obsolete. One real candidate: AgentCardPublicUrlMiddleware (190 LOC) — prebid#680 means transport="both" + callable public_url now works. Replacing it with a public_url=resolver callable will land separately. Two workarounds the bump can't eliminate, filed upstream: - serve(lifespan=) hook missing — adcp-client-python#709 - cross-class entity overrides still need type:ignore[assignment] — adcp-client-python#710 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lution (prebid#380) Wonderstruck-class publishers ship bare ``authorized_agents`` entries (``{url, authorized_for}`` only, no ``authorization_type``) alongside a top-level ``properties[]`` block. The AdCP SDK's strict resolver returns ``[]`` for these, so: - Publisher Partnerships chip rendered "Pending 0/0" — misleading operators into thinking the publisher hadn't authorized us yet. - Products UI used to bind anyway via a homegrown heuristic, then a prior pass tightened it to match the SDK — regressing Wonderstruck. This change introduces a four-state ``PublisherPartnerStatusKind`` (``authorized`` | ``unbound`` | ``pending`` | ``no_properties`` | ``unreachable``) and an explicit permissive resolution path: - ``aao_lookup_service.get_publisher_partner_status`` uses the SDK strictly first; falls back to ``unbound`` only when our entry is bare and the file has top-level properties. Surfaces a conformance hint so operators can nudge the publisher to add a typed binding. - ``property_discovery_service._extract_properties`` mirrors the same classification and, on the unbound branch, gates top-level properties to those carrying a ``type=domain`` identifier matching the publisher_domain — closes the attack vector where a publisher could bare-list us + claim arbitrary app/podcast/DOOH bundle IDs. - Shared shape helpers in ``src/services/_adagents_shapes.py`` (``is_bare_entry``, ``find_agent_entry``, ``top_level_properties``) cover the full schema selector set including ``signal_ids`` / ``signal_tags``. - New nullable ``aao_status_kind`` column on ``publisher_partners`` — legacy NULL rows fall back to the existing derivation in ``_partner_to_dict`` so the rollout is safe under rolling deploys. - JS chip styles for ``unbound`` ("Authorized (non-conformant file)") and ``no_properties`` ("No properties listed"). Upstream issues filed in parallel for ecosystem alignment: - adcontextprotocol/adcp#4478 — typed ``authorization_type: "all_top_level_properties"`` variant so publishers have a spec-conformant shape; once shipped we can deprecate the local permissive shim. - adcontextprotocol/adcp-client-python#711 — permissive resolver API. - adcontextprotocol/adcp-client#1721 — TS SDK per-agent resolution + permissive mode for cross-SDK consistency. Fixes prebid#377 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…NVALID_STATE, WWW-Authenticate (prebid#383) * fix(compliance): residual fixes from 7.1.0 probe — INVALID_REQUEST, INVALID_STATE, WWW-Authenticate Closes three residual storyboard failures observed in the 7.1.0 comply() re-probe against Wonderstruck after prebid#348/prebid#349 fixes deployed: 1. **error_compliance/nonexistent_product** — pre-dispatch validation in ``_create_media_buy_impl`` raised ``ValueError`` (past start_time, reversed dates, etc.) and the outer ``except (ValueError, PermissionError)`` handler emitted ``Error(code="VALIDATION_ERROR")``. ``VALIDATION_ERROR`` is not in the AdCP 3.0 ``STANDARD_ERROR_CODES`` enum, so buyer agents walking the enum for self-correction silently drop the error. Change wire code to spec-canonical ``INVALID_REQUEST``. Storyboard expects ``PRODUCT_NOT_FOUND``, ``PRODUCT_UNAVAILABLE``, or ``INVALID_REQUEST``; sibling ``reversed_dates_error`` accepts ``VALIDATION_ERROR`` or ``INVALID_REQUEST``. ``INVALID_REQUEST`` is the only value in both sets and is the spec-canonical choice. 2. **media_buy_state_machine/pause_canceled_buy** — ``_update_media_buy_impl`` had a terminal-state guard on cancel (re-cancel raises ``AdCPNotCancellableError``) but the pause/resume branch dispatched straight to the adapter. Spec requires rejection with ``/adcp_error/code == "INVALID_STATE"`` for pause-of-canceled. New exception ``AdCPInvalidStateError`` (``error_code="INVALID_STATE"``, recovery ``correctable``, 422) covers the symmetric guard. Fires BEFORE adapter dispatch on both terminal states (``canceled``, ``completed``) for both actions (``paused=True``, ``paused=False``). Idempotency-spec friendly: same payload yields the same wire code on retry regardless of which adapter would have handled the transition. 3. **security_baseline/probe_unauth** — RFC 6750 §3 requires a ``WWW-Authenticate: Bearer`` header on every 401 from a Bearer-protected resource. Upstream ``adcp.server.auth.BearerTokenAuthMiddleware`` on the MCP leg returns 401 without the header for missing/invalid tokens; the A2A leg and ``SigningVerifyMiddleware`` already emit it correctly. New ``WWWAuthenticateMiddleware`` (in ``core/middleware/``) wraps the ASGI ``send`` callable and injects the bare ``Bearer`` challenge on 401 responses missing the header. Case-insensitive presence check so stacking is safe; no-op on 2xx / 3xx / 4xx-other / 5xx so a 403 doesn't confuse buyers about which auth scheme to apply. Registered AFTER ``AdminWSGIMount`` so Google-OAuth-gated admin paths short-circuit before the buyer-protocol challenge sees them. Bundled together because they ship in a single redeploy cycle and the PR-title-check enforces one Conventional Commit prefix per PR; the three fixes are independent at the code level (different files, different behavioural surfaces, different tests). ## Residuals still open (not in this PR) - ``pagination_integrity_list_accounts/first_page`` — ``has_more`` returns false on a 3-seeded list with ``max_results=2``. Pagination logic in ``_apply_pagination`` is correct in isolation; the storyboard's seed→list chain isn't reaching the impl with the expected request shape. Needs separate investigation with end-to-end repro. - ``media_buy_seller/proposal_finalize/get_products_refine`` — refine path on ``get_products`` returns no ``proposals[]``. ``SalesAgentProposalManager.refine_products`` raises ``UNSUPPORTED_FEATURE``. Substantial feature work, separate PR. - ``security_baseline/assert_mechanism`` — likely fixed transitively by the ``WWW-Authenticate`` header; re-probe after deploy will confirm. ## Verification - ``make quality`` — 4292 passed, 14 skipped, 19 xfailed - New targeted tests: 30/30 (15 middleware × scope cases, 6 INVALID_STATE behavioural × class cases, 9 INVALID_REQUEST schema cases) - Existing ``test_max_daily_spend_exceeded`` updated to expect the new wire code per the change description - Structural guards (transport-agnostic-impl, no-toolerror-in-impl, etc.) pass; the new middleware is a salesagent-side ASGI wrapper, not in ``_impl`` scope Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(comply): mark WWWAuthenticateMiddleware as workaround for adcp-client-python#712 The upstream defect is in ``adcp/server/auth.py:411`` — ``BearerTokenAuthMiddleware._unauthenticated`` emits a ``JSONResponse`` with ``status_code=401`` but no ``WWW-Authenticate`` header. The sibling ``A2ABearerAuthMiddleware._send_unauthenticated`` in the same file (line 1024) gets it right. Filed at adcontextprotocol/adcp-client-python#712. Documents the deletion plan: when the upstream fix ships and we bump ``adcp``, the middleware's case-insensitive presence check makes it a no-op, so the order is safe — bump → re-probe → remove the middleware and its registration in a follow-up PR. No code change. Comments only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review(comply): code-reviewer nits from PR prebid#383 Fixes two factually-wrong claims and adds a regression guard, all flagged by the code-reviewer pass: 1. ``media_buy_create.py:2418`` comment claimed "``VALIDATION_ERROR`` is not in the spec enum and gets dropped by buyer agents walking ``STANDARD_ERROR_CODES``." It IS in the enum (``adcp/types/generated_poc/enums/error_code.py:46``). Replace with the actual justification — the storyboard-intersection argument — and add a forward note about the dead ``PermissionError`` catch path (no code inside this try raises it today; if a future principal- ownership check moves in, split the except so PermissionError maps to ``PERMISSION_DENIED``). 2. ``test_invalid_request_envelope_on_validation_failure.py`` carried the same wrong claim in its module docstring. Rewrite to reflect the actual intersection argument. 3. Add ``test_www_authenticate_runs_after_admin_mount_and_before_signing`` to ``test_serve_kwargs_middleware_order.py`` — pins ``WWWAuthenticateMiddleware`` between ``AdminWSGIMount`` (so admin Google-OAuth 401s don't get a misleading Bearer challenge) and ``SigningVerifyMiddleware`` (so signing-emitted 401s flow through the injector). A future refactor that moves the middleware either direction surfaces here instead of silently breaking RFC 6750 §3 compliance. No behaviour change. Quality: 4293 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ne=True (prebid#385) * feat(proposal): implement v1 refine_products + flip capabilities.refine=True Closes the ``media_buy_seller/proposal_finalize/get_products_refine`` storyboard failure observed in the 7.1.0 comply() probe against Wonderstruck. ## What changed * ``SalesAgentProposalManager.refine_products`` now has a real implementation instead of raising ``UNSUPPORTED_FEATURE``. Delegates to ``_get_products_impl`` for products, decorates the response with a fresh ``Proposal`` via the existing ``_build_v1_brief_proposal`` even-split allocator, and populates ``refinement_applied[]`` from the buyer's ``refine[]`` asks. * ``ProposalCapabilities.refine`` flipped from ``False`` to ``True``. The framework router now dispatches ``buying_mode='refine'`` requests to ``refine_products`` instead of falling through to ``get_products`` (which never populated ``refinement_applied``). * New ``_build_v1_refinement_applied`` helper: dispatches each refine entry's ``scope`` (``request`` / ``product`` / ``proposal``) to the matching ``RefinementApplied{1,2,3}`` variant. Status is uniformly ``applied`` with a v1-acknowledgement note explaining the response carries a fresh-but-unchanged-strategy proposal. Forward-compat: unknown scopes and malformed entries (e.g. product-scope without ``product_id``) are silently dropped rather than crashing the response. ## v1 vs v2 semantics v1 is explicitly acknowledgement-shaped. Storyboard validation is ``field_present @ /proposals`` and ``response_schema`` — both satisfied without semantic refinement. The note in each ``refinement_applied`` entry signals the v1 limitation so buyers see honest behaviour: the proposal is fresh but the allocation hasn't been re-strategized from the ask content. v2 will swap the even-split for an allocation that actually honors asks (drop product / shift budget / shape targeting) once ``ProposalStore`` is wired to load the prior draft by ``proposal_id``. The wire contract stays stable across v1/v2. ## Tests Mirrors the pattern in ``test_proposal_manager_brief.py``: * ``TestSalesAgentProposalManagerCapabilities`` pins ``capabilities.refine=True`` and the unchanged sales_specialism. * ``TestBuildV1RefinementApplied`` covers every scope variant, multi-entry ordering preservation, malformed-entry drop, unknown- scope drop, and RootModel-wrapped entry unwrap. * ``TestRefinementAppliedNote`` pins the buyer-facing breadcrumb so a future content swap is intentional. 11 new tests; quality green (4273 passed, 14 skipped, 19 xfailed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review(refine): cap buyer-supplied echo + drop dead fallback (PR prebid#385 nits) Addresses three review items: **security-reviewer L1 (Should-Fix): bound buyer-supplied refine echo.** ``RefinementApplied2.product_id`` and ``RefinementApplied3.proposal_id`` are typed ``str`` with no length cap in the adcp library, so an adversarial buyer could ship 10MB ids and force us to hold them through Pydantic validation and echo them back. Added two caps in ``core/proposal/manager.py``: * ``_MAX_REFINE_ID_LEN = 256`` — per-id length cap; oversize ids are DROPPED (not truncated — truncation corrupts id semantics for downstream correlation). Real AdCP ids look like ``prop_abc123`` / ``prod_video_outdoor``; 256 chars leaves generous headroom. * ``_MAX_REFINE_ENTRIES = 50`` — array length cap, slice up front so an N-million-entry array can't drive allocation pressure even before the per-entry loop runs. * New ``_is_safe_id`` helper centralizes the per-id check (also catches non-str values, defense-in-depth for callers bypassing Pydantic). **code-reviewer nit 1: drop dead ``or getattr(req, "refine", None)``.** ``_coerce_to_request_model`` returns a ``GetProductsRequest`` Pydantic model that always has the ``refine`` attribute (default ``None``), so the fallback can never fire. Simplified to ``getattr(req_model, "refine", None) or []``. **code-reviewer nit 2: drop over-promised telemetry comment.** The dropped-scope comment claimed "missing telemetry" without actually emitting any. Replaced with the honest framing — forward-compat for spec additions, known v1 limitation tracked for v2 telemetry — and matched the docstring's "silently dropped" claim. ## New tests (6) ``TestRefineEchoLengthCaps`` in ``test_proposal_manager_refine.py``: * ``test_oversized_product_id_dropped`` — 257-char id (cap+1) dropped * ``test_oversized_proposal_id_dropped`` — same cap on proposal scope * ``test_empty_product_id_dropped`` — zero-length symmetry * ``test_max_length_id_accepted`` — boundary at exactly 256 chars * ``test_excess_array_length_truncated`` — 100-entry array → 50 echoed * ``test_non_string_product_id_dropped`` — defense-in-depth for non-str Quality green: 4279 passed, 14 skipped, 19 xfailed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r): DetachedInstanceError (prebid#392) * fix(prebid#336): enable Add Publisher on embedded view Embedded tenants couldn't add publisher partners — the UI hid the controls and the API 403'd direct POSTs. Without publishers there are no AuthorizedProperty rows, which empties the property selector and blocks Create Product on embedded tenants. PublisherPartner is not in the model-layer guard's locked set (embedded_tenant_guard locks only Tenant core columns, AdapterConfig, and signing creds), so publisher-partner mutations are publisher-managed by definition. Apply the same opt-in pattern as PR prebid#340: pass allow_embedded_writes=True on the four mutation routes (add / delete / sync / refresh) and drop the redundant _reject_if_embedded helper. Template: unhide the +Add Publisher / Refresh-all buttons and the modal; update the "Platform-managed" banner to scope only to the agent URL (which IS platform-managed) rather than the partner roster. Tests: flip TestPublisherPartnershipsReadonlyOnEmbedded → TestPublisherPartnershipsEditableOnEmbedded; add positive coverage test_managed_tenant_can_add_publisher_partner under TestEmbeddedViewAllowsPublisherManagedWrites. Move the api-mode JSON envelope assertion and gate-polarity check to the OIDC enable route (still platform-managed, api_mode=True). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(scheduler): DetachedInstanceError on multi-buy delivery batch Production trace (2026-05-08): the daily delivery-report batch succeeded for the first media buy with a reporting_webhook, then raised DetachedInstanceError on media_buy.tenant for every subsequent buy in the same batch. Root cause: each iteration calls _get_media_buy_delivery_impl, which opens its own ``with get_db_session()``. Because get_db_session uses a scoped_session, the inner ``scoped.remove()`` closes the SAME session the outer batch loop is using, detaching every MediaBuy row loaded by MediaBuyRepository.get_all_by_statuses. The first iteration happens to complete before the inner remove() fires; iteration 2+ hits a detached instance on the next relationship access. Fix: eager-load MediaBuy.tenant via joinedload in the scheduler's fetch. The tenant value is materialized into the instance state and survives detach, so media_buy.tenant returns the cached Tenant without lazy-loading through a closed session. Added eager_load_tenant=True parameter on MediaBuyRepository.get_all_by_statuses (default False so the media_buy_status_scheduler caller — which doesn't access tenant — doesn't pay the JOIN cost). Regression test reproduces the production trace exactly: two media buys with reporting_webhook configured; without the fix, only one webhook is sent and the second iteration raises DetachedInstanceError. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(prebid#335): regression tests for product-save validation paths The user reported "Internal Server Error" when saving a product without selecting a Property, expecting a validation flash instead. PR prebid#340 closed prebid#335 by fixing the embedded-write 403 that the storefront proxy was misreporting; these tests document and lock in the post-fix contract so the bug can't return undetected. Six new scenarios under tests/admin/test_product_creation_integration.py: - test_add_product_without_property_returns_validation_error_not_500: POST with name + pricing, no property selection. Asserts 200 + the "Please select at least one property tag" flash text + no leaked Product row. - test_add_product_malformed_inputs_never_return_500 (parametrized): only_name, name_and_pricing_only, invalid_pricing_rate, invalid_property_mode, property_ids_mode_no_selection. Every case must surface a validation response, never a raw 500. Both tests share a new ``authenticated_admin`` fixture that uses UserFactory (CLAUDE.md Pattern #8) — re-loads the tenant inside the factory's session to avoid DetachedInstanceError from the test_tenant fixture's closed session. All 17 product-create + delivery-webhook integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: tighten scheduler regression + bound display_name + pin guard layer Addresses code-review and security-review feedback on the three preceding commits before merge. Scheduler test (code-review C2): tie the regression assertion to the actual failure mode via caplog. Without this, the test depended on ``await_count`` as a second-order signal; the new check catches ``DetachedInstanceError`` directly so a future refactor that changes the send count for unrelated reasons can't silently mask the bug. Guard layer consistency (code-review I4): new test ``test_publisher_partner_not_locked_at_model_layer`` exercises a PublisherPartner write on an embedded tenant without the ``management_api_caller`` bypass. If a future change adds the model to ``embedded_tenant_guard``'s locked set, this test fails with a pointer to remove ``allow_embedded_writes=True`` from the four publisher_partners routes. Companion note added in embedded_tenant_guard.py near the existing locked-table listeners. Display-name length cap (security nit 1): add a 255-char gate on ``display_name`` in ``add_publisher_partner`` so a hostile or buggy embedded caller can't persist multi-MB strings that later render into admin UI / API responses. Filed prebid#391 for the systemic scoped_session/nested-get_db_session() trap surfaced during scheduler triage (code-review C1). The scheduler fix in ac3a3a7 is the right immediate patch; the underlying trap needs its own redesign and is tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e mounts work (prebid#393) * fix(prebid#357): use url_for() for tenant admin links so embedded-mode mounts work The Setup Checklist (and other admin surfaces) emitted bare /tenant/<id>/... hrefs. Under the Storefront's /storefront/salesagent mount, those resolved against the storefront host instead of the proxied salesagent path and returned 404 from the parent app. Service layer (setup_checklist_service.py, dashboard_service.py, business_activity_service.py) now builds URLs via flask.url_for() so the emitted hrefs include SCRIPT_NAME automatically. Templates that previously hand-prepended {{ request.script_root }} are migrated to url_for() in the same pass for consistency with CLAUDE.md Pattern #6. SetupChecklistService runs from two transports: Flask (admin UI) and Starlette via adcp.server.serve() (MCP/A2A). validate_setup_complete() fires inside _create_media_buy_impl on the non-Flask path, where url_for() would raise RuntimeError. _build_url() catches that and returns None; validate_setup_complete only reads task['name'], so the gate behavior is unchanged. Added tests/unit/test_setup_checklist_no_flask_context.py to pin this contract. JS string interpolations (`${tenantId}/...`) are intentionally untouched — url_for can't help with runtime IDs, and CLAUDE.md Pattern #6 already endorses `scriptRoot + path` for that case. One pre-existing FIXME left: tenant_settings.html /settings/raw form posts to a route that has no handler; touched only with a clarifying comment, not a behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(prebid#357): also tolerate BuildError when url_for runs in a foreign Flask app The tenant_management_api blueprint runs as its own Flask app (tests/integration/test_managed_tenant_api.py:54 — a bare Flask() with only that blueprint registered). When SetupChecklistService is invoked from that app (via tenant_status_service → /status), url_for() for admin-UI endpoints raises werkzeug.routing.BuildError, not RuntimeError, because the endpoint isn't registered there. _build_url now catches both. The management API never reads action_url (it surfaces configure_path from a static map in tenant_status_service._CONFIGURE_PATHS), so None is correct. Adds TestServiceWorksInForeignFlaskApp to pin the contract — Flask context exists, but the endpoint can't be built. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rebid#394) * chore(deps): bump adcp 5.3.0 → 5.4.0; drop three local workarounds 5.4.0 ships every upstream issue I filed yesterday plus a bonus. ## Drops with the bump | Workaround | Upstream fix | |---|---| | ``core/middleware/www_authenticate.py`` (74 LOC) — injected ``WWW-Authenticate: Bearer`` on 401 because the MCP-leg ``BearerTokenAuthMiddleware._unauthenticated`` returned ``JSONResponse(status_code=401)`` without the header | adcp-client-python#712 → prebid#715: ``_unauthenticated`` now emits the header on both the MCP dispatch path AND the ASGI ``_send_unauthenticated`` path, matching what the A2A leg already did | | ``core/idempotency._ReplayMarkingStore`` (~120 LOC + private-symbol coupling to ``_WRAPPED_FUNCTIONS`` / ``_clone_response`` / ``_resolve_call_args`` / ``_to_dict`` / ``CachedResponse``) — reimplemented the full ``IdempotencyStore.wrap`` body inline to inject ``replayed: true`` on cache hits per AdCP L1/security rule 4 | adcp-client-python#714 → prebid#717: ``IdempotencyStore.wrap`` now does ``response["replayed"] = True`` on the cache-hit branch natively | | ``mcp_header_name="x-adcp-auth"`` + ``mcp_bearer_prefix_required=False`` — the MCP leg accepted ONLY the custom legacy header, EXCLUDING the spec-canonical ``Authorization: Bearer``. Caused ``security_baseline/probe_api_key`` storyboard failures | adcp-client-python#720 → prebid#721: ``Authorization: Bearer`` is always accepted; ``mcp_legacy_header_aliases=[...]`` is a purely additive opt-in for adopters with deployed legacy clients | Net diff: -533 LOC including the obsolete test files. ## Auth shape after bump Old (broken for spec-compliant clients): ```python BearerTokenAuth( validate_token=_validate_token, mcp_header_name="x-adcp-auth", mcp_bearer_prefix_required=False, ) ``` New (spec compliance + zero break for legacy clients): ```python BearerTokenAuth( validate_token=_validate_token, mcp_legacy_header_aliases=["x-adcp-auth"], ) ``` ``Authorization: Bearer <token>`` is now accepted on both legs by default (the spec carrier per RFC 6750). The ``x-adcp-auth`` legacy header keeps working unchanged for any early-adopter MCP client still on it. Migration is a one-way drift with no flag day. ## Files removed - ``core/middleware/www_authenticate.py`` - ``core/tests/test_idempotency_replay_marking.py`` - ``tests/unit/test_www_authenticate_middleware.py`` - The ``WWWAuthenticateMiddleware``-ordering test in ``tests/unit/test_serve_kwargs_middleware_order.py`` ## Verification - ``make quality``: 4294 passed, 14 skipped, 19 xfailed - Existing ``_ReplayMarkingStore`` callers in ``get_idempotency_store()`` swapped to plain ``IdempotencyStore`` — same constructor signature, upstream provides the injection - ``test_serve_kwargs_middleware_order.py`` updated to drop the ``WWWAuthenticateMiddleware``-position pin (middleware no longer exists) - After deploy, the compliance probe's ``security_baseline/probe_api_key`` and ``assert_mechanism`` storyboard steps should flip to pass — closes the auth-header gap we filed as bokelley#386 (which can now be closed as "fixed upstream") ## Closes (when deployed) - bokelley#386 — multi-header auth (now native via prebid#720) - The remaining compliance-probe residual on ``security_baseline`` (3 → 1 failure; only ``proposal_finalize/create_media_buy`` left, tracked separately as prebid#387) ## Doesn't pick up - 5.4.0's ``LazyPlatformRouter.proposal_stores=`` / ``proposal_store_factory=`` (prebid#722/prebid#724) — this is the wiring point for bokelley#387. Separate PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review(deps): switch test fixtures to new BearerTokenAuth shape (PR prebid#394 nit) Code-reviewer flagged that two test files still constructed ``BearerTokenAuth`` with the legacy ``mcp_header_name`` / ``mcp_bearer_prefix_required`` kwargs even though production swapped to ``mcp_legacy_header_aliases=[...]`` in this PR's main commit. The tests passed against 5.4.0 (back-compat shim works) but emitted ``DeprecationWarning`` and stopped mirroring the production config — production now ACCEPTS ``Authorization: Bearer`` on the MCP leg alongside ``x-adcp-auth``, but these test fixtures still wired the old exclusive-header semantics. ## Changes * ``tests/unit/test_per_leg_bearer_auth.py``: ``_production_auth`` and ``_build_mcp_app`` updated to the new shape. The inner ``BearerTokenAuthMiddleware`` construction now passes ``legacy_header_aliases=auth.resolved_mcp_legacy_aliases()`` and ``legacy_aliases_bearer_prefix_required=auth.legacy_aliases_bearer_prefix_required`` in place of the deprecated ``header_name`` / ``bearer_prefix_required`` pair. Mirrors what adcp.server.serve._wrap_mcp_with_auth does natively against 5.4.0. * ``tests/unit/test_agent_card_auth_scheme.py``: ``_production_auth`` same swap; module-level docstring updated to reflect that both legs now default to ``Authorization`` and the MCP leg additively accepts ``x-adcp-auth`` for legacy adopters (not as an exclusive override). ## Verification * 10/10 targeted tests pass * ``make quality`` — 4294 passed, 14 skipped, 19 xfailed; warning count dropped from 117 to 105 (the deprecation warnings are gone) No behavior change beyond what PR prebid#394's main commit ships. Tests now exercise the same wire shape production uses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ebid#395) Production log volume on Fly was 70%+ noise. Three categories of offender, all in our code (not the adcp SDK): src/core/context_manager.py - Delete leftover ``console.print`` debug blocks: 🔍 PRE-COMMIT WEBHOOK DEBUG (16 lines per workflow step update), 🔍 POST-COMMIT WEBHOOK DEBUG (5 lines), and the 🚀 WEBHOOK /⚠️ WEBHOOK SKIPPED chatter. These read as leftover instrumentation from a past debugging session and fire on every workflow step status change. - Convert remaining ``console.print`` calls to ``logger.debug`` (lifecycle events: context/step creation, object linking, webhook dispatch) or ``logger.warning`` / ``logger.exception`` (errors). - Drop the unused ``rich.console.Console`` import and module-level ``console = Console()`` singleton. src/core/helpers/adapter_helpers.py - Demote ``[ADAPTER_SELECT]`` / ``[ADAPTER_CONFIG]`` from ``logger.info`` to ``logger.debug`` (10 call sites). These are tracing-grade fields, not operational signals — they should be off in production unless someone is actively debugging adapter selection. src/core/audit_logger.py - Collapse the per-detail audit fan-out: instead of one ``logger.info`` per ``details`` dict key (N+1 lines per audit event), emit a single ``"<message> | <details>"`` line. Full details still persist to ``AuditLog.details`` for structured queries — the per-line fan-out was legible in local tail but flooded production stdout. The ``adcp.audit`` logger name is shared with the SDK's ``LoggingAuditSink`` but the noisy emissions come from our own ``audit_logger`` in ``src/core/audit_logger.py``, not the SDK — so nothing to file upstream. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ix trafficker_id log bug (prebid#397) Three followups from the audit pass on production fly logs. src/core/logging_config.py - Add ``UvicornAccessNoiseFilter`` and attach it to ``uvicorn.access`` in both production (JSON) and development (standard) modes. The filter drops 2xx GET/POST/HEAD/OPTIONS access lines on /mcp[/] and /health — the two endpoints hit constantly by storefront MCP pollers and Fly's TCP+HTTP health checks. 4xx/5xx still surface so auth failures and server errors aren't buried. Other paths (admin UI, /a2a, /.well-known, /mcp-debug, etc.) are unaffected. Behavioral contract pinned by 18 parametrized tests in tests/unit/test_uvicorn_access_filter.py. src/adapters/gam/managers/targeting.py - Rate-limit the "Could not load geo mappings file" + "Using empty geo mappings" warnings to once per process lifetime via a module-level flag. Each ``GAMTargetingManager`` instance fires on every adapter selection, so the same warning flooded the log on every GAM-tenant request. The underlying file-not-found is still tracked in prebid#396 (it means GAM geo targeting silently produces empty results in prod and needs a packaging-side fix). src/adapters/google_ad_manager.py - Fix the "Could not auto-detect trafficker_id: User instance has no attribute 'get'" warning. The googleads SOAP client returns a zeep complex object — it supports __getitem__ and attribute access but NOT ``.get()``. The old code called ``current_user.get('name', 'Unknown')`` inside the success-log f-string, which raised AttributeError AFTER ``self.trafficker_id`` was already assigned. The ID was being detected correctly all along; only the success log was failing and producing a misleading warning on every request. Switched to ``getattr`` for the optional ``name`` field. Filed prebid#396 to track the underlying production OOM kill on the iad machine and the missing ``gam_geo_mappings.json`` packaging issue — both infrastructure-level and out of scope here. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uy(proposal_id=…) (prebid#390) * feat(proposal): wire Postgres-backed ProposalStore for create_media_buy(proposal_id=…) Closes the proposal-lookup gap that made ``proposal_finalize/create_media_buy`` fail with ``INVALID_REQUEST: Invalid budget: 0.0``. Without a wired :class:`ProposalStore`, the framework's ``proposal_dispatch`` had no backing for the buyer's ``proposal_id`` and ``create_media_buy`` landed in package-derivation with zero packages. Pieces: - ``proposals`` table (migration ``r0s1t2u3v4w5``) — mirrors the v1.5 ``ProposalRecord`` dataclass with multi-tenant scoping and a partial unique on ``(account_id, media_buy_id) WHERE media_buy_id IS NOT NULL`` for reverse-index lookups - :class:`SalesAgentProposalStore` — implements every :class:`adcp.decisioning.proposal_store.ProposalStore` Protocol method (put_draft / get / commit / try_reserve_consumption / finalize_consumption / release_consumption / mark_consumed / discard / get_by_media_buy_id) against the new table. Atomic CAS via ``SELECT … FOR UPDATE`` serializes parallel callers. Cross-tenant probes collapse to ``None`` / ``PROPOSAL_NOT_FOUND`` per the Protocol's principal-enumeration defense. - :class:`_LazyPlatformRouterWithStore` — thin subclass that adds the ``proposal_store_for_tenant`` accessor the framework's ``proposal_dispatch`` duck-types. Upstream :class:`LazyPlatformRouter` doesn't expose it (only the eager :class:`PlatformRouter` does, via ``proposal_stores=``). - Wired into ``build_router()`` — single shared store across tenants; isolation runs inside the store on ``expected_account_id``. v1 lifecycle compromise (documented in the store docstring): the storyboard flow goes brief → create_media_buy WITHOUT an intermediate finalize step, but the framework's :meth:`try_reserve_consumption` requires the proposal to be in ``committed`` state. The store auto-commits at ``put_draft`` time with a 7-day ``expires_at`` so the buyer flow unblocks today. The Protocol surface is unchanged — only the internal lifecycle state differs. When the manager declares ``finalize=True`` in v2, swap to canonical ``draft`` + explicit commit. Tests: - ``tests/integration/test_proposal_store.py`` — 15 integration tests against real Postgres covering put_draft auto-commit, payload round-trip, refine overwrite, cross-tenant probe defense (get + try_reserve), two-phase consumption lifecycle, atomic CAS double-reservation rejection, reverse-index lookup with ``expected_account_id`` enforcement, idempotent release/discard - ``tests/unit/test_lazy_router_with_proposal_store.py`` — 3 unit tests pinning the router subclass's accessor wiring - ``tests/unit/test_proposal_store_attributes.py`` — 2 unit tests pinning ``is_durable=True`` (production-mode gate) and the 7-day default hold window Refs prebid#387 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(proposal): adopt adcp 5.4 — drop workarounds, use upstream surface Upstream shipped both items we filed during prebid#387: - adcp-client-python#722 → 5.4: LazyPlatformRouter accepts ``proposal_stores=`` and ``proposal_store_factory=``. Deletes our ``_LazyPlatformRouterWithStore`` subclass. - adcp-client-python#723 → 5.4: ``ProposalCapabilities.auto_commit_on_put_draft`` shipped option B from the issue. The framework now calls ``store.commit`` immediately after ``put_draft`` for opted-in managers. Deletes our store-side ``state=COMMITTED`` workaround in ``put_draft``. Migration: - Bump ``adcp>=5.4.0``. - ``SalesAgentProposalManager.capabilities`` declares ``auto_commit_on_put_draft=True``; framework owns the DRAFT → COMMITTED promotion via ``auto_commit_ttl_seconds=604800`` (7-day default, matches our prior store-side hold window). - ``core/main.build_router`` calls ``LazyPlatformRouter(...)`` directly with ``proposal_store_factory=lambda _tid: shared_store``. Factory shape over eager dict because the store is a single shared instance — eager dict would force boot-time tenant enumeration and miss tenants registered after boot. - ``SalesAgentProposalStore.put_draft`` writes spec-canonical ``draft`` state with ``expires_at=None``. The ``_committed_hold`` constructor param and the 7-day default are gone — the framework's ``auto_commit_ttl_seconds`` capability owns the TTL. Tests: - Integration: 16 tests rewritten — put_draft asserts DRAFT (not COMMITTED), reservation lifecycle tests use a ``_put_and_commit`` helper that mirrors the framework's auto-commit dispatch, new ``TestCommit`` class covers commit promotion + idempotency + payload-drift rejection, new test pins that put_draft on a COMMITTED record raises ``INTERNAL_ERROR`` per Protocol. - Unit: deleted ``test_lazy_router_with_proposal_store.py`` (no subclass to test); trimmed ``test_proposal_store_attributes.py`` to the durability flag only (the 7-day default belongs to the framework now). Refs prebid#387 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proposal): address review — split compound account_id, account-scoped locks, fail-closed unscoped methods Review feedback on PR prebid#390: **B1 (blocker): _resolve_tenant_id_for_account returned account_id verbatim.** SalesagentAccountStore.resolve mints ``f"{tenant_id}:{ref}"`` (``ref`` defaults to ``"default"``; storyboard runs use ``"acct_demo"``). The framework passes ``ctx.account.id`` straight into ``put_draft``, so every prod ``put_draft`` would FK-violate on ``proposals.tenant_id``. Fixed: split on ``":"`` and take the prefix. New integration test ``test_put_draft_handles_compound_account_id`` regresses this — uses the real shape the framework emits. **Security MAJOR (×3): try_reserve / finalize / release did SELECT FOR UPDATE then filtered account_id in Python.** Cross-tenant probes acquired the row lock, leaking existence via timing AND providing a DoS primitive against legitimate same-tenant operations. Fixed: ``account_id`` moved into the WHERE clause so cross-tenant probes never acquire the lock. Two new integration tests pin the behavior: - test_finalize_cross_tenant_collapses_to_internal_error - test_release_cross_tenant_is_noop (verifies foreign tenant's release doesn't roll back the owner's CONSUMING reservation) **Security MAJOR (×2): discard() and mark_consumed() Protocol signatures lack ``expected_account_id``.** Any caller obtaining a ``proposal_id`` could destroy / terminate another tenant's proposal. Neither is called by adcp 5.4's ``proposal_dispatch`` today; fixed: both raise ``NotImplementedError`` with an ERROR log. Future framework versions that begin calling them surface loudly before reaching prod. Two new tests pin the fail-closed behavior. **MAJOR M3: _serialize_recipes silently passed dicts through.** Violates "No quiet failures" (CLAUDE.md). Fixed: raises TypeError on non-Pydantic input — caller has to pass typed Recipe instances. **MINOR m3: lazy imports inside every method.** Hoisted ``ProposalRecord``, ``ProposalState``, ``AdcpError`` to module level — no circular import; the salesagent already imports the library at module-load time elsewhere. **NIT n2/n3: stale temporal references.** Dropped "v1 auto-commit workaround landed before prebid#723 and is gone" from the store docstring and "v1 auto-commits at put_draft time" from the Proposal model docstring. Per CLAUDE.md: don't document the prior behavior. **M2 partial coverage: end-to-end account_id shape test added.** ``test_put_draft_handles_compound_account_id`` exercises the realistic ``"tenant_id:default"`` shape the framework actually emits. Full end-to-end (HTTP → proposal_dispatch → store) deferred to compliance probe post-deploy — the unit layer pins every store-side invariant. 24 integration + unit tests pass; ``make quality`` clean (4311 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review(proposal): expires_at guard + 8 lock-in tests cherry-picked from PR prebid#398 Two additions from @bokelley's parallel prebid#398 work that prebid#390 lacked: **1. Defense-in-depth expires_at check inside try_reserve_consumption.** Security reviewer L1 finding on prebid#398: a buyer holding a COMMITTED proposal past its ``expires_at`` could reserve and finalize indefinitely. The framework's ``proposal_dispatch._hydrate_proposal_context`` checks expiry on the get-side, but ``try_reserve_consumption`` is reachable from dispatch paths that bypass that filter (and from adopter callers that go straight to the store). New three-line guard inside the existing row lock raises ``PROPOSAL_EXPIRED`` with ``recovery="correctable"``. Mirrors upstream :class:`InMemoryProposalStore._evict_expired_locked` but surfaces the event rather than silently deleting so audit trails survive. **2. mark_consumed restored as implemented Protocol method.** Earlier fail-closed pattern was over-cautious for a Protocol method the framework doesn't currently call. Now matches the upstream :class:`InMemoryProposalStore.mark_consumed` shape verbatim, with a WARNING audit log on every call so unexpected invocations are visible. Documented Protocol-signature gap (no ``expected_account_id``) — same upstream constraint that :meth:`discard` has; ``discard`` stays fail-closed because the user's follow-up list didn't include it. **Tests (9 added, 1 replaced):** - test_reserve_past_expires_at_raises_expired (locks in #1) - test_release_silent_no_op_on_missing - test_release_silent_no_op_on_cross_account - test_finalize_idempotent_on_consumed_matching_media_buy - test_finalize_mismatched_media_buy_raises - test_mark_consumed_promotes_to_consumed - test_mark_consumed_idempotent_on_matching - test_mark_consumed_mismatched_raises - test_mark_consumed_unknown_raises_internal_error - Replaced ``test_mark_consumed_raises_not_implemented`` with the four ``TestMarkConsumed`` cases above All cherry-picked from prebid#398's test suite (locked-in shapes already correct in prebid#390's code per @bokelley's close comment). 32 integration + unit tests pass; ``make quality`` clean (4311 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…des (prebid#400) adcp 5.4.0 prebid#718 ships ``SchemaVariant[T]`` + a mypy plugin that rewrites the annotation to ``Any`` for override-compat purposes, retiring the ``# type: ignore[assignment]`` stamps adopters used to carry on cross-class entity overrides. The 12 sites in src/core/schemas/ all match the cross-class pattern the marker targets: - 4× geo_*_exclude — parent declares Geo{Country,Region,Metro, PostalArea}ExcludeItem; we substitute the inclusion variant - 2× creatives — parent declares CreativeAsset; we substitute our extended Creative - 1× deployments — parent declares Deployments; we substitute SignalDeployment - 1× media_buys — parent declares MediaBuy; we substitute the GetMediaBuysMediaBuy delivery-context view - 1× ext — parent declares ExtensionObject; we use dict - 1× sync_creatives.creatives — parent's CreativeAsset; we use our local CreativeAsset subclass - 1× query_summary — parent's QuerySummary; we use our local - 1× media_buy_deliveries / 1× creatives in delivery.py — delivery-context views mypy.ini gets ``adcp.types.mypy_plugin`` added to the plugins line alongside the existing sqlalchemy + pydantic plugins. Tradeoff (documented upstream): inside the override, mypy sees the field as ``Any``. ``typing.cast(list[T], self.field)`` recovers precise inference at call sites that need it. None of the touched sites currently rely on inside-override inference at usage sites, so no cast() is needed for this change. make quality: 4319 passed, 14 skipped, 19 xfailed. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_shutdown=) (prebid#401) adcp 5.4.0 prebid#713 ships native lifespan hooks on ``serve(transport='both')``, which is exactly what the middleware was hand-rolling. The middleware intercepted ASGI ``lifespan.startup`` / ``lifespan.shutdown`` scope events to fire scheduler start/stop coroutines because earlier SDK versions didn't expose a user-supplied lifespan extension point. Now they do. The SDK's ``on_startup`` / ``on_shutdown`` kwargs take the same ``Callable[[], Awaitable[None]]`` shape that ``_start_schedulers`` and ``_stop_schedulers`` already had, so the swap is mechanical: - Drop ``SchedulerLifespanMiddleware`` from the ``asgi_middleware`` list. - Pass ``on_startup=[_start_schedulers]`` / ``on_shutdown=[_stop_schedulers]`` in ``_serve_kwargs()``, conditional on ``include_scheduler`` (tests still skip). - Delete ``core/middleware/scheduler_lifespan.py`` (61 LOC). - Update the ``_serve_kwargs`` docstring to reference the SDK hook instead. The middleware ran scheduler shutdown with a 10s ``asyncio.wait_for`` guard; the SDK fires hooks unguarded. Our ``_stop_schedulers`` already caps its own awaitables (delivery + media-buy status schedulers each join their internal task groups with a bounded timeout), so dropping the outer wait_for is fine — it was defensive double-bookkeeping. make quality: 4319 passed, 14 skipped, 19 xfailed. Closes the second of three local rip-outs unlocked by the adcp 5.4.0 bump. The third (AgentCardPublicUrlMiddleware → public_url callable) lands separately. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rebid#402) The salesagent middleware existed because earlier SDK versions either hardcoded ``http://localhost:{port}/`` into the agent card with no override hook (pre-5.0) or crashed ``transport='both'`` startup when ``public_url`` was a callable (5.2.0, ``AttributeError: 'function' object has no attribute 'router'``). adcp 5.3.0 prebid#680 fixed the composed-lifespan crash. 5.4.0 has confirmed the callable path works under ``transport='both'`` in production. The SDK's ``serve(public_url=PublicUrlResolver)`` is now the right primitive for per-request agent-card URL derivation. ## What lands - ``core/main._resolve_public_url(request) -> str`` — pure function with the same header-precedence rules the middleware enforced: PUBLIC_URL env > X-Forwarded-Host > Host, X-Forwarded-Proto for scheme, ``http://`` for loopback / ``https://`` otherwise. - Wired as ``"public_url": _resolve_public_url`` in ``_serve_kwargs``. - Drop the middleware from the ``asgi_middleware`` list. - Delete ``core/middleware/agent_card_public_url.py`` (189 LOC). - Replace ``test_agent_card_public_url_middleware.py`` with 13 tests of the new resolver covering: X-Forwarded-Host precedence, Host fallback, comma-chain stripping, proto override, https default, loopback http exception (matches SDK's ``_validate_card_url``), PUBLIC_URL env override, no-headers fallback. - Update ``test_serve_kwargs_middleware_order`` — replace the middleware-present assertion with a ``public_url is callable`` assertion. ## Net diff -442 LOC (mostly the middleware + ASGI plumbing tests it required) +195 LOC (resolver doc + resolver tests + updated order test) = -247 LOC net. ## What stays the same in production behavior - PUBLIC_URL env takes precedence (single-host deploys unchanged). - X-Forwarded-Host derives multi-tenant subdomain URLs (same as before). - X-Forwarded-Proto controls scheme. - Loopback hosts get ``http://`` (the SDK's _validate_card_url enforces this — non-loopback ``http`` returns 500 from the SDK). ## What's different (intentional) - The middleware refused to rewrite non-loopback URLs (defensive pass-through). The resolver always derives the URL afresh. This is safer: with the static-public_url fallback gone, the resolver is the single source of truth and there's no "what gets rewritten vs passed through" branching to reason about. - Response-body buffering and content-length recalculation are gone — the SDK builds the card from the resolver's URL directly. make quality: 4322 passed, 14 skipped, 19 xfailed. Closes the third of three local rip-outs unlocked by the adcp 5.4.0 bump (after SchemaVariant migration and SchedulerLifespanMiddleware removal). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rebid#403) Reflection-based export/import for tenant-scoped data. Walks SQLAlchemy metadata to discover all 41 tenant-scoped tables (including transitive chains like media_packages → media_buys, strategy_states → strategies, object_workflow_mapping → workflow_steps), then exports/imports rows in FK-dependency order inside a single transaction. Built for moving clients from legacy hosting to embedded mode (flip is_embedded=True) on the same Postgres deployment. Also supports cross-deployment moves via target-tenant-id retargeting and a strip-secrets mode that wipes Fernet ciphertext + plaintext bearer credentials (admin_token, slack/audit/hitl webhook URLs, GAM refresh token, push_notification_configs auth, webhook subscription secret hash, creative/signals agent auth_credentials, ai_config api_key). principals.access_token is intentionally preserved so buyers' MCP/A2A integrations keep working post-import. Safety: - alembic_revision pinned in the bundle; import refuses on schema mismatch - pre-flight collision check on subdomain, virtual_host, principals.access_token raises TenantImportCollisionError with precise message instead of opaque IntegrityError - strict column filtering when alembic revisions match (drops are a bug, not noise); --allow-schema-drift downgrades to warning - Core-level inserts bypass the embedded_tenant_guard ORM listeners; the operator-CLI trust boundary is the equivalent privilege level - export bundle written 0600 (contains tenant secrets) - import writes an audit_logs row capturing operator, mode, flip-to-embedded, target_tenant_id, row counts - explicit rollback on any import-path failure CLIs: scripts/ops/export_tenant.py acme --out acme.json [--strip-secrets] scripts/ops/import_tenant.py acme.json --mode=replace --flip-to-embedded scripts/ops/import_tenant.py acme.json --target-tenant-id new --allow-schema-drift scripts/ops/import_tenant.py acme.json --dry-run 19 integration tests covering discovery, round-trip, collision modes, embedded flip, retargeting, strip-secrets (encrypted + plaintext bearer), strict filtering, schema mismatch, audit log emission. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…01 from A2A (prebid#407) Crawlers and probes hitting `GET /robots.txt` on the API host fell through to the inner A2A app, where BearerTokenAuth 401'd them. Production logs filled with `"GET /robots.txt HTTP/1.1" 401 Unauthorized` (plus a paired `adcp.server.auth` JSON line per rejection), and well-behaved crawlers got an inconsistent signal — 401 is not a stable "do not crawl" answer. robots.txt is a host-level resource, not a per-tenant one, so neither Flask nor A2A is the right owner. `AdminWSGIMount` already short- circuits an analogous static response (the apex `/` → `/signup` 302), so colocate the robots short-circuit there: - `GET`/`HEAD /robots.txt` → 200 `text/plain` with `User-agent: *\nDisallow: /\n` and `cache-control: public, max-age=86400` - non-safe methods (POST, etc.) fall through unchanged — the short circuit only covers actual crawler probes Tests: four scenarios in `TestAdminWSGIMountRobotsTxt` covering the GET body+headers, HEAD-returns-no-body contract, POST falling through, and the bug itself (the request must not reach the inner A2A app on a non-admin host). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prebid#408) Surfaces session/connection auth-flag state directly in the EmbeddedTenantWriteError message so SyncJob.error_message (and the status widget that renders it) shows exactly why the guard fired without log-diving. Distinguishes the three failure modes: - session_present=False → object was detached at flush time - session_flags={all None/False} → flag never set on this session - session_flags={one True} → guard misread the flag (should be impossible) No behavior change beyond the longer error text.
…rebid#410) Anonymous internet traffic hammers /mcp constantly (bot probes, misconfigured clients), and every rejection emits two log lines: - one uvicorn access line ("POST /mcp HTTP/1.1" 401 Unauthorized) - one structured ``adcp.server.auth`` line ("a2a auth rejected" …) PR prebid#397's filter deliberately kept 4xx/5xx so auth failures weren't buried, but the structured log already captures the signal — the access line is dupe noise. In production logs this is by far the dominant source of /mcp-related log volume. Per-surface status-code policy now: * /mcp[/] — drop 2xx AND 401. Other 4xx (403/404/422) and all 5xx still log; those indicate a real problem worth investigating. * /health — drop 2xx only. A 4xx/5xx on the health surface always means a config or platform bug worth seeing. Implementation splits the single regex into two named patterns so the status-code carve-out per surface stays readable. Test reshuffle: * test_drops_noise — new combined parametrize: /mcp 2xx + /mcp 401 + /health 2xx (one row per cause). * test_keeps_real_signal — /mcp non-401 4xx (403, 404, 422), /mcp 5xx, /health non-2xx (401, 503), and /.well-known/oauth-protected-resource 401 (the OAuth dance start, which is signal not noise). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…argeting, formats (prebid#381) * chore(freewheel): capture & anonymize publisher API fixtures Adds 56 anonymized FreeWheel Publisher API response fixtures covering /services/v3/ (XML, commercial: advertisers, campaigns, insertion_orders, placements, agencies) and /services/v4/ (JSON, inventory: sites, site_sections, site_groups, series, videos, video_groups, inventory_packages). Includes the capture and anonymization scripts under scripts/dev/freewheel/ so fixtures can be regenerated when the test bearer token rotates (7-day TTL). Both scripts read identifying constants from env vars rather than embedding them, keeping the source tree free of publisher-specific identifiers. .env.template documents the two new optional vars. Anonymization scrubs PII (sales person, trafficker, content credits) and publisher-identifying values (network_id, advertiser_id, content/title fields, external Salesforce IDs) while preserving referential integrity via deterministic memoized replacements. Structural fields (statuses, stages, currencies, budget shapes, schedules, link hrefs) are preserved verbatim so fixtures remain useful as wire-format ground truth for the upcoming adapter client. No production code wired yet — these fixtures are the foundation for the FreeWheel adapter client rewrite (next change). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): rewrite adapter for real /services/v3+v4 API surface Replaces the skeletal OAuth-client-credentials client (wrong path shape, wrong content type, wrong auth model) with a bearer-token client that matches the actual FreeWheel Publisher API surface verified end-to-end against a publisher's test network: - /services/v4/* (JSON, inventory taxonomy: sites, sections, series, videos, video groups, inventory packages) — read-only - /services/v3/* (XML, commercial entities: advertisers, campaigns, insertion orders, placements, agencies) — full reads + verified create_campaign/delete_campaign writes Module layout under src/adapters/freewheel/: _transport.py — bearer auth, accept negotiation, status mapping _inventory.py — v4 JSON inventory client _commercial.py — v3 XML commercial client _pagination.py — shared page-walking iterator (DRY) entities.py — Pydantic models for both surfaces client.py — FreeWheelClient facade composing the above Connection config is now a single api_token field (7-day TTL, no refresh flow — rotate when expiry approaches). Migration of existing tenants will require manual reconfiguration once any are provisioned. Tests: - 37 new unit tests across transport / inventory / commercial replaying captured fixtures from tests/fixtures/data/freewheel/ - Updated config schema + adapter + roundtrip integration tests Adapter-level wiring (create_media_buy/update_media_buy/check_status using the new client) is intentionally deferred to a follow-up PR; live mode still returns pending_credentials until that integration lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): add insertion-order, placement, and campaign-update writes Completes the v3 write surface so adapters and external callers can construct a full campaign hierarchy. All endpoints verified end-to-end against the publisher's test network (probe entities created with clearly tagged names, then deleted): - POST /services/v3/insertion_order — min body: name + campaign_id. Server defaults: stage=NOT_BOOKED, currency=EUR. Auto-attaches an assigned_user from the bearer token's identity (silently dropped by our model's extra="ignore" config). - POST /services/v3/placement — min body: name + insertion_order_id. Server defaults: status=IN_ACTIVE, placement_type=NORMAL. - PUT /services/v3/campaign/{id} — partial update. PATCH returns 405, so v3 uses PUT semantics for "only fields in the body are modified". - DELETE /services/v3/insertion_order/{id} and DELETE /services/v3/placement/{id} — hard deletes, same shape as campaign delete. Adds put_xml() to the transport (POST handler already existed) and a matching test for the PUT method. 6 new commercial client tests covering create+delete for IO and placement, and the partial-update semantics for update_campaign (verifies only passed fields appear in the request body). create_media_buy wiring still uses the pending_credentials stub — that work belongs in the adapter-mapping PR where we decide how AdCP Package maps onto FreeWheel's Campaign→IO→Placement hierarchy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): wire create_media_buy and check_status to live v3 API Replaces the pending_credentials stub in create_media_buy with the real v3 write flow per Mapping A: AdCP MediaBuy → FW Insertion Order (the commercial transaction) AdCP Package → FW Placement (one per package, child of the IO) FW Campaign → per-buy wrapper above the IO In live mode the adapter now: 1. Creates a FW Campaign named after the AdCP buy (po_number-derived or timestamp), parented to the principal's freewheel advertiser_id. 2. Creates a FW Insertion Order under that campaign. 3. Creates one FW Placement per AdCP Package under the IO. 4. Returns ``media_buy_id = "freewheel_{io.id}"`` — the IO is the unit of commerce, so it's what subsequent calls reference. check_media_buy_status now fetches the IO (not the Campaign) and reports its ``stage`` (NOT_BOOKED, BOOKED, etc.), which is where IO booking state lives in v3. Falls back to ``status`` for safety. FreeWheelError from any of the three create calls is translated to a CreateMediaBuyError with code ``upstream_error``. Partial-failure orphans (e.g. Campaign created then IO fails) are not cleaned up in v1 — they sit as IN_ACTIVE entities and don't deliver. A best-effort rollback is a future refinement. Deferred (each its own follow-up, flagged in the adapter docstring): - update_media_buy live wiring (needs update_io/update_placement probes) - add_creative_assets (v3 /creative endpoint returned 404 in probes — the creative surface is somewhere we haven't mapped) - get_media_buy_delivery (reporting lives on a different API surface) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): add update_insertion_order/update_placement and document scope blockers Adds the v3 partial-update verbs to the commercial client, both verified end-to-end against the publisher's test network (probe entities created and deleted): - update_insertion_order(id, **fields) — PUT /services/v3/insertion_order/{id}. Supports nested-dict fields like ``budget={"budget_model": ..., "impression": ...}`` for impression-target adjustments. - update_placement(id, **fields) — PUT /services/v3/placement/{id}. The delivery-level pause/resume mechanism (status=IN_ACTIVE / ACTIVE). Extends _build_xml to handle one level of nested dicts so partial body updates with nested elements (budget, schedule) serialise correctly. Adapter-level update_media_buy wiring intentionally NOT included — two publisher-scope blockers surfaced during probes that need resolution before the adapter can wire cleanly: 1. Per-package operations need AdCP package_id -> FW placement_id lookup. v3 /placements doesn't honour ?insertion_order_id filter (returns full network list); no nested-collection endpoint at v3; v4 has the nested form but our token gets a 403 IAM deny. 2. Per-package budget changes don't fit FW's data model — budget lives on the IO, not on the placement. Would need a different mapping (one-IO-per-package) or per-package tracking we don't have. Creative endpoints discovered at v4 (creatives, creative_assets, assets, ad_assets, asset_versions, creative_versions all 403 IAM-deny). v3 has no creative paths (404). add_creative_assets wiring blocked on publisher granting creative scopes. Documented in the adapter docstring so the next conversation with the publisher has the asks ready. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(freewheel): clarify the creative endpoint split (publisher vs demand) After a docs deep-dive prompted by the user pointing at the FreeWheel Marketplace Creatives reference, we now have a clearer picture of the two-API creative model and the AdCP semantic mismatch it implies. Publisher-side (the API our bearer is for): PUT /services/v4/mkpl_creatives/{id} body: approval_status (Approved|Rejected|Pending) + approval_notes This is a *moderation* workflow, not a creation workflow. The buyer registers the creative through their own DSP; it shows up in the publisher's marketplace queue; the publisher (us, via Talpa's token) approves or rejects it. AdCP's sync_creatives (buyer registering creatives) therefore has no direct publisher-side equivalent — the adapter's approval surface maps to AdCP's creative review/approval flow, not its creation flow. Three sibling type-specific endpoints exist alongside the unified one (mkpl_exchange_programmatic_creatives, mkpl_private_direct_sold_creatives, mkpl_private_programmatic_creatives). All 403 IAM-deny on our token — the ask to Mathijs becomes specific: grant scope on ``/services/v4/mkpl_creatives``. Buyer-side (POST /demand/v1/accounts/{seat_id}/ads) is the FreeWheel Demand/Beeswax product. Out of scope for publisher-token-driven integration — Talpa as a publisher wouldn't have a Demand seat to delegate. No code change beyond the adapter docstring — just capturing the finding so the next round of asks to the publisher is precise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): add v4 creative_resources client (full CRUD scope verified) After two earlier docs links pointed at the wrong API surface (Demand v1 and Marketplace approval), the user surfaced https://api-docs.freewheel.tv/publisher/reference/creative-management-api-v4 which gave us the correct path: /services/v4/creative_resources. Probing shows our existing publisher bearer is fully entitled there — we just hadn't tried the right name. Verified end-to-end (2026-05-12): - GET /services/v4/creative_resources (list, 70 creatives) - GET /services/v4/creative_resources/{id} (single, ``{creative: {...}}`` envelope) - POST /services/v4/creative_resources (auth+validation reaches us) - PUT /services/v4/creative_resources/{id} (auth+validation reaches us) - ?include=renditions exposes the nested VAST tag URIs inline. Exposed on the client as ``client.creatives`` with list_creatives, get_creative, and iter_creatives. CRUD writes (POST/PUT/DELETE) are deferred to a follow-up commit so we can probe shapes against the live API with a create+cleanup pattern. Still scope-blocked (publisher must grant): - /services/v4/creative_instances — creative <-> placement linkage, needed to actually attach a creative to a placement so it delivers. - /services/v4/creative_renditions — standalone rendition collection. - /services/v4/mkpl_creatives — marketplace creative approval. Supporting changes: - entities.py: Creative + Rendition + CreativeMessage models. Extended PaginatedResponse with AliasChoices so both pagination conventions work (total_count/total_page for inventory, total/total_pages for creative_resources). - capture_fixtures.py: added creative_resources to the v4 walk. - anonymize_fixtures.py: advertiser_ids / agency_ids (list-of-int) plus uri and clearcast_note added to the scrub list. VAST URIs replaced with example.invalid placeholders so the third-party ad-server hostnames don't leak. - tests/helpers/freewheel_replay.py: shared make_response / replay_session helpers extracted from the three client test files to satisfy the code-duplication guard. Fixture file churn comes from the deterministic counter-based anonymiser picking different fake-name values now that creative_resources is in the input set; semantic content is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): live smoke test + fix XML empty-element coercion Adds a @pytest.mark.live integration test that exercises the whole FreeWheel client stack against the real publisher API: token info, inventory reads, commercial reads, creative reads, and a full Campaign → IO → Placement create-and-delete cycle. Skipped by default, runs only when FREEWHEEL_TEST_API_KEY + FREEWHEEL_TEST_ADVERTISER_ID are set. Running the test surfaced a real bug. The live API returns campaigns with ``<agency_id></agency_id>`` when no agency is assigned (empty XML element), and our ``_element_to_dict`` was emitting ``""`` for those — which Pydantic ``int | None`` fields couldn't coerce. Fixed by mapping empty leaf elements to ``None`` instead of ``""`` so optional scalar fields validate cleanly across the board. The earlier BeforeValidator on nested model fields (schedule/budget) becomes redundant for the empty-element case but stays in place as a safety net. Live test results against Talpa's network (2026-05-12): TestAuthAndConnectivity.test_token_info_returns_user_and_expiry PASS TestInventoryReads.test_list_sites_returns_entities PASS TestInventoryReads.test_list_videos_returns_entities PASS TestCommercialReads.test_list_advertisers_includes_test_advertiser PASS TestCreativeReads.test_list_creatives_returns_entities PASS TestWriteRoundTrip.test_full_create_and_delete_cycle PASS The write round-trip creates Campaign → IO → Placement, fetches the IO back, then deletes everything in reverse order. All six assertions land and all three deletes succeed — Mapping A wires correctly end-to-end through to the real API. Registered a ``live`` pytest marker so the suite doesn't need @pytest.mark.skipif boilerplate on every test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): OAuth2 password-grant auth (api_token kept as escape hatch) Adds the canonical FreeWheel auth flow — username + password — alongside the pre-existing pre-minted api_token path. Production users now enter credentials once; the transport mints a bearer at POST /auth/token on first use, caches it with TTL tracking, and auto-refreshes on 401 or expiry. The api_token field is kept as an escape hatch for cases where a partner has provisioned a token out-of-band (our Talpa setup), or for testing without managing real credentials. Exactly one of (username + password) or api_token is required, enforced at three layers: - Pydantic model_validator on FreeWheelConnectionConfig - Constructor check in FreeWheelTransport - Init check in FreeWheelAdapter (live mode only) Transport behaviour: - api_token mode: bearer used directly, 401 propagates to caller. - password-grant mode: mint via POST /auth/token (data: grant_type=password, user_id, password). 401 triggers exactly one refresh + retry before propagating, in case the cached token rolled prematurely. expires_in is honoured with a 1-hour refresh leeway (or expires_in/2, whichever is smaller). UI: connection_config.html now has a "Sign-in Credentials (recommended)" section with User ID + Password fields, plus an "Advanced: pre-minted bearer token" <details> block for the escape hatch. The Save flow rejects submissions that have neither path. The Test Connection flow reports ``auth_mode: password_grant`` vs ``pre_minted_token`` in its response. Tenant-status reporting accepts either auth path. Config save/update endpoints accept username, password, and api_token, reject ciphertext replay on both secret fields, and pass the merged config to FreeWheelConnectionConfig for validation. Tests: - 15 new unit tests (password-grant mint + cache + 401-refresh + retry + error paths, plus full schema validation coverage for both auth paths). - Integration roundtrip tests cover both auth modes. - Live API test continues to pass via the api_token path (we don't have Talpa's username/password to exercise the password-grant path against the real API; that's unit-tested only until a real user/password pair shows up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): inventory taxonomy sync into local cache Adds a publisher-internal cache of FreeWheel inventory so the adapter's product setup UI can pick targeting from FW taxonomy without round-tripping to the FW API on every page render. New ``freewheel_inventory`` table (alembic 7c3073bd70cf), keyed by ``(tenant_id, entity_type, entity_id)`` with a denormalised name/parent_id plus a full JSON-blob ``raw_json`` payload. Stores eight entity kinds: - site (v4) - site_section (v4) - site_group (v4) - series (v4) - video_group (v4) - ad_unit_package (v4, with nested ad_units folded out) - ad_unit (v4, denormalised from ad_unit_packages — bare /ad_units/{id} is 403-denied on our scope, so we read them through their packages) - ad_unit_node (v3 XML; binds placement → ad_unit, read-only at v3) - standard_attribute (v4 reference data — TV ratings, languages, etc.) Individual Videos are NOT synced (4,613+ items on Talpa's network; query on-demand if a product needs to drill into a specific asset). This table is NOT exposed to AdCP buyers. Buyer-facing property discovery goes through the AAO lookup path (adagents.json + brand.json, via src/services/aao_lookup_service.py). The cache exists purely for the publisher's product configuration UI. See #378 for the cleanup of the deprecated AuthorizedProperty / PropertyTag tables that this design intentionally bypasses. Components: - alembic 7c3073bd70cf — create freewheel_inventory table - src/core/database/models.py — FreeWheelInventory ORM model - src/adapters/freewheel/inventory_sync.py — FreeWheelInventorySync service: walks every readable family, upserts via Postgres ON CONFLICT DO UPDATE. Per-family errors are captured in SyncResult rather than aborting (partial-success policy — some tenants will have uneven scope coverage across families). - POST /api/tenant/<id>/adapters/freewheel/sync-inventory — admin endpoint that reads the stored config, instantiates a client, and triggers the sync. - templates/adapters/freewheel/connection_config.html — "Sync Inventory Now" button + status display showing per-entity-type counts. - 7 new unit tests covering SyncResult dataclass, the dispatch orchestration with a mock client, partial-failure semantics, and the standard_attributes flat-dict code path. Verified end-to-end against Talpa's live network: 2,542 entities synced in one call (29 sites, 51 site_sections, 96 site_groups, 324 series, 507 video_groups, 2 ad_unit_packages, 385 ad_unit_nodes, 1148 standard_attributes), then re-runs upsert cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): product setup UI driven by synced inventory cache The FreeWheel product config schema and template are rebuilt around the actual FW data model. Instead of asking publishers to type comma-separated "placement IDs" (the old shape, which never made sense for our adapter since placements get created per buy), the new product setup UI pulls choices from the local ``freewheel_inventory`` cache: - Sites (delivery destinations) - Site Sections (optional sub-section scoping) - Video Groups (audience-segmented content — Talpa's primary targeting primitive: "DOELGROEP INDEX 150+", etc.) - Series (specific shows) - Ad Unit Package (slot bundle: Pre-Mid, Pre-Mid-Post) - TV Ratings (content rating restrictions, from standard_attributes) The picker template (templates/adapters/freewheel/product_config.html) populates each <select> via fetch() against a new admin endpoint ``GET /api/tenant/<id>/adapters/freewheel/inventory?entity_type=X``, which reads from the cache through a new tenant-scoped repository (FreeWheelInventoryRepository in src/core/database/repositories/). Schema changes: - FreeWheelProductConfig now exposes the real inventory targeting fields (site_ids, site_section_ids, video_group_ids, series_ids, ad_unit_package_id, tv_rating_ids) plus pricing controls (price_model: ACTUAL_ECPM / FIXED_PRICE, priority). - placement_ids is dropped — that field was based on the GAM model where placements pre-exist. Doesn't apply to FW's per-buy create. - targeting_profile_id and custom_targeting kept under an Advanced <details> as escape hatches. Bug fix in inventory_sync: - The /services/v4/ad_unit_packages list endpoint returns metadata only — nested ad_units only appear on the single-item GET. The sync used to read the list and silently miss the ad_units. Now it fans out to each package's detail and dedupes ad_units across packages (Pre-roll Ad belongs to both Pre-Mid and Pre-Mid-Post; first-write wins on parent_id). - _sync_ad_unit_packages now returns (package_count, ad_unit_count); the SyncResult tracks both as separate counts. Verified end-to-end against Talpa's live network: 2,545 entities synced (was 2,542 before this commit; the new ad_unit count is 3 — Pre-roll, Mid-roll, Post-roll — deduped from the two packages). Repository pattern: - New FreeWheelInventoryRepository — tenant-scoped reads against the freewheel_inventory table. The admin inventory endpoint goes through this rather than raw select() (structural guard test_architecture_no_raw_select.py was enforcing this). Adapter dry-run logs now mirror the new product config shape (site_ids, video_group_ids, etc.) so what the operator sees in dry-run matches what they configured. What this does NOT do yet (still blocked on v4 scope grants): - Drive actual delivery targeting. The product config carries the intent (which sites/video_groups/etc. to target), but binding that to FW ad_unit_nodes at buy time requires v4 ad_unit_nodes write scope. See adapter.py docstring for the full block list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): full targeting surface + canonical VAST video formats Two related expansions of the FreeWheel adapter, both based on the audit of standard_attributes we synced from Talpa's network. ## FreeWheelProductConfig — every supported targeting dimension The standard_attributes sync from Phase G pulled in 1,148 targeting primitives across 15 categories. Most are *structured equivalents* of what GAM publishers express through custom key-values — FW models them as typed first-class fields rather than free-form k/v pairs. The product config now exposes every one we have synced data for: Inventory site_ids, site_section_ids, video_group_ids, series_ids, ad_unit_package_id Audience viewership_profile_ids (29 standardized profiles), audience_item_ids (Data Suite — gated, stored only) Content genre_ids (426), content_daypart_ids (3), content_duration_ids (3), content_territory_ids (251), language_ids (42), tv_rating_ids (266) Delivery context device_type_ids (76), os_ids (3), environment_ids (2), stream_type_ids (7), subscription_model_ids (3) Privacy addressability_ids (32), privacy_signal_ids (3) Pricing price_model (ACTUAL_ECPM | FIXED_PRICE), priority This gives FreeWheel publishers the full expressive range of their network through the product setup UI. Each new dimension is backed by a <select> picker that loads from the freewheel_inventory cache via the existing GET /api/tenant/<id>/adapters/freewheel/inventory endpoint. The adapter's dry-run _line_item_payload echoes every list dimension so operators can verify intent in dry-run logs before flipping to live mode. Note: custom_targeting (the FW v4 custom_keys API) is still gated by scope on our token — kept as the escape hatch under an Advanced <details>, but most use cases that would have needed it on GAM are covered by the structured fields above. ## FreeWheelAdapter.get_creative_formats() — six canonical VAST formats New src/adapters/freewheel/formats.py declares six static AdCP-shaped formats covering pre/mid/post-roll × 15s/30s: freewheel_video_15s_pre_roll freewheel_video_30s_pre_roll freewheel_video_15s_mid_roll freewheel_video_30s_mid_roll freewheel_video_15s_post_roll freewheel_video_30s_post_roll Each format declares a single VAST tag URL asset and {vast: true} delivery hint. Validated against adcp.types.Format on every test run. Declared statically (Option A) rather than synthesised from synced data because (a) AdCP's format registry is mostly static, (b) the six combinations cover the common buyer case for video VAST forwarding, and (c) static format IDs stay stable across inventory-sync runs so buyer references don't break when Talpa edits their ad_unit_packages. Tests: - 6 unit tests for the static format list and Format schema validation - Schema test for the new product config fields, full round-trip via model_dump → model_validate 4328 unit tests pass. Live FW integration test still green via api_token escape hatch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): surface synced inventory via get_available_inventory + README refresh Two free wins that need no FW scope grant: - Override AdServerAdapter.get_available_inventory() to serve the AI product configurator from the freewheel_inventory cache. Returns placements (ad_unit_packages), ad_units (sites + site_sections), targeting_options (standard_attributes grouped by taxonomy key), the static VAST creative specs, and cache properties. Live-verified against Talpa: 2 packages, 80 ad units, 15 targeting groups, 6 formats, 1148 attributes. - Rewrite docs/adapters/freewheel/README.md to match what's actually shipped: password-grant auth (with api_token escape hatch), full inventory sync taxonomy, 18-dimension product config, live coverage matrix, and the layered scope-grant ask (Tier 1: lifecycle; Tier 2: reporting; Tier 3: operator UX; Tier 4: future). Previous README still described the client_credentials path we abandoned and claimed skeleton-only status. Test: tests/unit/test_freewheel_adapter.py::TestGetAvailableInventory covers shape and grouping semantics with mocked repository. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): reporting cache scaffold — read paths + stub sync (scope-pending) Build everything that's anchored on AdCP's contract (which is fixed) so day-of-scope the only new work is the actual FW Reporting API client. Adds: - Migration + ORM: freewheel_placement_stats cache table (per-placement impressions/spend_micros/completed_views/clicks/currency/delivery_status, keyed by (tenant_id, placement_id), with IO-scoped index for delivery aggregation). Spend stored in micros to dodge floating-point drift. - Repository (FreeWheelPlacementStatsRepository): tenant-scoped reads via get_by_placement_ids() and list_by_insertion_order(), plus a Postgres ON CONFLICT bulk_upsert() for the sync job to call. - get_packages_snapshot(): reads from cache, returns Snapshot per package. Missing rows surface as None so callers render a "no data" state rather than fail. Staleness derived from row.as_of. Delivery status mapped to the AdCP DeliveryStatus enum where the FW value maps cleanly. - get_media_buy_delivery(): aggregates per-placement rows into DeliveryTotals + by_package list. Empty cache falls through to the base helper's zero-response shape. - FreeWheelReportingSync stub: raises ReportingScopeNotGranted with a pointer to the README scope ask. Schedulers can catch this and degrade gracefully — read paths already tolerate the empty-cache state. Tests pin the read-side contract (tests/unit/test_freewheel_reporting_cache.py, 7 cases). When FW grants Tier 2 scope, the only new work is implementing the four private methods on FreeWheelReportingSync (submit_job, poll_job, fetch_results, parse_rows) against the real Query Reporting endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tenant-mgmt-api): typed adapter configs + GET /adapters discovery Two gaps that blocked Scope3 from using anything beyond GAM + Mock through the typed tenant-management API: 1. The AdapterConfig discriminated union in src/admin/api_schemas/ tenant_management.py only listed GAM + Mock. Typed embedder clients couldn't POST type="freewheel" / "triton" / "broadstreet" — spectree validation rejected anything else, even though the legacy /api/tenant/<id>/adapter-config endpoint (operator-facing) handled them. Adds: - FreeWheelAdapterConfig (with the username+password OR api_token cross-field rule) - TritonAdapterConfig (auth_type + creds + base/login URLs) - BroadstreetAdapterConfig (network_id + api_key) Secrets use SecretStr. Persistence round-trips through each adapter's own connection schema so Fernet encryption lands consistently in AdapterConfig.config_json — same path the legacy endpoint uses. 2. No way to discover what adapters this Sales Agent instance supports. Adds GET /api/v1/tenant-management/adapters returning the full catalog per adapter type: name, description, default_channels, capabilities (mirrors AdapterCapabilities), and the connection_schema JSON Schema so embedders can validate locally before POSTing. Sourced from ADAPTER_REGISTRY so new adapters auto-appear once they're registered and have a typed AdapterConfig member. Test plan: - tests/unit/test_tenant_management_schemas.py: 13 new schema-level tests covering each typed config's happy path + rejection paths + discriminator routing through ProvisionTenantRequest. - tests/integration/test_tenant_management_api_integration.py: 2 new endpoint tests (catalog shape + auth gate). - Regenerated docs/api/tenant-management-openapi.{json,yaml}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(adapters): adapter playbook — phase-by-phase checklist for new adapters 11-phase walkthrough of everything needed to add a new ad-server adapter end-to-end: pre-work API probing, adapter package scaffolding, inventory + reporting caches, three-place registration (registry / typed API config / discovery catalog), admin UI, admin endpoints, test coverage, docs, OpenAPI regeneration, smoke + quality gates, common gotchas, ship. FreeWheel is called out as the reference implementation with specific file pointers per step. Captures the lessons from PR #381 — stale uvicorn imports, migration head collisions, DeliveryStatus enum mismatch, BuildKit stale-deps surprise, the DRY guard ratchet, etc. Also fixes the stale FreeWheel description in docs/adapters/README.md (client_credentials → password grant) and surfaces the new playbook from the index. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(tenant-mgmt-api): add tier flag to adapter discovery (mock=test, rest=live) Emma flagged that the discovery endpoint exposes Mock to embedders — which is real (it's a registered adapter we use in tests and demos) but should never appear in a production storefront's picker. Adds: - ``tier`` field on AdapterCatalogEntry: ``"live"`` (production adapter) or ``"test"`` (simulated/dev-only). Mock is the only ``"test"`` adapter today; everything else is ``"live"``. - ``?tier=live`` and ``?tier=test`` query filter on GET /adapters so production storefronts can opt out of seeing the test surface server-side (rather than having every embedder filter client-side). Unknown values return 400. Default behaviour returns all adapters with their tier tag so dev consoles keep seeing the full set. Production storefronts pass ?tier=live. Test plan: - 3 new endpoint tests in test_tenant_management_api_integration.py (live filter excludes Mock, test filter returns only Mock, invalid value rejected with 400) — all green. - Existing catalog assertion updated to check the new tier field. - Regenerated docs/api/tenant-management-openapi.{json,yaml}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(adapters): park Triton — APIs not production-ready, surface removed Triton told us their TAP Media Buying API isn't production-ready (2026-05). Surface-removal approach (vs. full deletion) so we can restore via revert when their APIs come back. Removed from every customer-facing surface: - ADAPTER_REGISTRY: triton + triton_digital entries dropped, so tenants cannot select 'triton' as ad_server (legacy POST /tenants now rejects it via an ADAPTER_REGISTRY membership check; spectree POST /tenants/provision rejects via the discriminated AdapterConfig union). - AdapterConfig discriminated union: TritonAdapterConfig removed. - Discovery catalog (_ADAPTER_CATALOG_METADATA + _ADAPTER_CONFIG_TYPED): triton excluded from GET /api/v1/tenant-management/adapters. - tenant_settings.html: picker card hidden (with a comment pointing at the parked module path for restoration). - adapters.py blueprint: test_triton_connection endpoint removed. - docs/adapters/README.md: Triton section + table row replaced with a short parked-state notice. - docs/adapters/triton/README.md → README.parked.md with a header explaining the parked state. Kept parked (so restoration is a revert, not a rebuild): - src/adapters/triton/* — the adapter module + client + targeting - tests/unit/test_triton_*.py — direct-construction tests still run; TestRegistry tests flipped to assert parked-state behaviour. - templates/adapters/triton/* — connection + product templates unreachable but preserved. - Alembic migrations — unchanged. Existing tenants whose adapter_type is already 'triton' (if any) remain operable: the update path in tenant_management_api.py preserves their config_json handling. Tests: - test_tenant_management_schemas.py: removed TritonAdapterConfig happy- path tests; added test_provision_request_rejects_parked_triton_adapter to pin the embedder-side rejection. - test_tenant_management_api_integration.py: catalog assertions exclude triton from both the all-adapters and tier=live responses. - test_new_product_filters.py + test_triton_adapter.py registry tests flipped to assert the parked-state behaviour. - 4,367 passed / 14 skipped / 19 xfailed — all green. - Regenerated docs/api/tenant-management-openapi.{json,yaml}. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(adapters): permission probes — catch IAM gaps at connect, not mid-campaign Brian flagged that operators today discover missing upstream permissions when a campaign fails halfway through, instead of seeing them at connect time. This adds a structured permission-check primitive on AdServerAdapter and a live FreeWheel implementation. The pattern: 1. AdServerAdapter.check_permissions() returns a PermissionsReport with per-endpoint PermissionCheck entries: name, description, granted, required vs nice-to-have, feature label (creative_trafficking, delivery_reporting, etc.), and a detail string for failed probes. 2. fully_operational rolls up to True only when every required probe passes. Optional probes can deny without blocking the rollup — surfaces partial-scope state correctly. 3. Each adapter implements its own probe — auth flows differ enough that a generic HTTP prober doesn't fit. Base class returns an empty report so adapters that haven't implemented yet behave as "no checks declared, fully_operational=True". FreeWheel implementation probes 14 endpoints covering auth, inventory sync, commercial CRUD, creative trafficking, reporting, audiences, targeting profiles, and webhooks — every AdCP feature path. Live probe against Talpa correctly reports our current state: 9 required probes granted, 1 required denied (/services/v4/ads — the creative trafficking blocker), 4 optional denied (reporting + audiences + targeting profiles + webhooks). Probe semantics that took some thought: - 4xx validation (400/404/422) counts as GRANTED — endpoint accepts the call, just needs different params. Our minimal probes intentionally send empty payloads so we don't accidentally mutate state. - 401/403 count as denied (real scope gap). - Auth-token failures bail the whole pass with report.error set; we don't paint every endpoint as "denied" when the real problem is a bad token, that'd mislead operators. New admin endpoint: POST /api/tenant/<tenant_id>/adapters/<adapter_type>/check-permissions Loads the configured adapter, runs check_permissions(), returns the JSON report. Read-only (every probe is a GET) so opts into the embedded-write gate. Available to admin or member roles. Test plan: - tests/unit/test_freewheel_permissions.py: 11 cases covering dry-run short-circuit, granted/denied semantics, the validation-error edge case, 401 mapping, auth failure handling, probe target cleanup, and the every-check-has-a-feature invariant. All pass. - Live-verified against Talpa: correctly identifies /services/v4/ads as the one required denial blocking creative trafficking. - make quality: 4,378 passed / 14 skipped / 19 xfailed. Follow-up not in this PR: UI rendering of the checklist on the adapter settings page; surfacing fully_operational on the discovery catalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): permission-check UI + correct creative_trafficking model Brian flagged that FW's docs don't actually have an "Ad" concept and asked us to verify. Re-reading the creative_instances POST docs revealed the parameter ad_id is described as "The Ad Unit Node ID to link Creative" — there is no separate Ad object. The /services/v4/ads scope we were chasing was misdirected. Verified live: POST /services/v4/creative_instances with ad_id=<ad_unit_node_id from inventory sync> and creative_id=<creative_resource_id> returns 201 Created with FW auto-deriving placement_id on the response. The entire creative trafficking flow is unblocked today. UI: - Added "Check API Permissions" button to the FreeWheel adapter settings page. Hits POST /api/tenant/<id>/adapters/freewheel/check-permissions, renders a per-feature checklist showing granted/denied with the probe target endpoint and the AWS API Gateway deny detail when the scope is missing. Operators see at-connect-time which AdCP features will work, instead of discovering missing scopes mid-campaign. Code: - check_permissions probe list: dropped v4_ads (wasn't needed); kept v4_creative_instances as the required probe with a comment explaining the ad_id ↔ ad_unit_node_id alias. - Unit tests updated to use creative_instances as the canonical required probe (11 cases still passing). - Live probe against Talpa now reports fully_operational=true. Only Tier-3/4 nice-to-haves remain denied (reporting, audiences, targeting profiles, webhooks). Docs: - README "Scope grants still needed" rewritten: Tier 1 ads grant is removed; Tier 1 is now Query Reporting (path TBD). Added a "What we no longer need to ask for" section explaining the ad_id alias and the v4-doesn't-exist-for-commercial finding so the next person looking at this doesn't go down the same dead ends. - Coverage matrix: add_creative_assets flipped from 🟡 partial (blocked) to ✅ unblocked; associate_creatives from ⏳ blocked to 🟡 wired-ready (FW writes work — adapter just needs the ad_unit_node lookup chain from cache, which is a follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): pinpoint Reporting API location, update scope ask + probes Probed FW's full host surface to find where their Reporting API actually lives — turns out it's at api.freewheel.tv/reporting/* (singular, host root, NOT under /services/v*). Every /reporting/* path returns the AWS API Gateway IAM-deny payload for our test user, confirming the resources exist and only a scope grant is needed. Verified surface (all currently denied): POST /reporting/jobs — submit async job GET /reporting/jobs/{id} — poll status GET /reporting/jobs/{id}/result(s)/download — fetch CSV/JSON GET /reporting/queries + /saved_queries — saved-query CRUD GET /reporting/dimensions + /metrics — schema introspection Adjacent host-root paths (/reports, /reporting at the top, /insights, /analytics, /graphql, etc.) all returned nginx-level HTML denies rather than AWS IAM-deny, confirming /reporting/* is the actual surface and others are dead-end aliases. Code: - check_permissions(): swapped the wrong /services/v4/reports probe for two correct /reporting/* probes (schema introspection + job submit). - reporting_sync.py: dropped TBD docstring; now documents the full /reporting/* surface map so day-of-scope is just filling in the four private methods. - Unit test parameter updated to match the new probe path. Docs: - README "Scope grants still needed" now lists the specific endpoints to ask for. Updates the Mathijs ask from "we don't know where reporting lives" to "grant our user IAM access to /reporting/* — specific paths listed". Live probe (against Talpa, user 35696) cleanly shows fully_operational=true plus two new entries under feature=delivery_reporting both denied, ready to flip the moment scope arrives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migrations): re-point FW merge revision to chain through r0s1t2u3v4w5 origin/main added migration r0s1t2u3v4w5_add_proposals_table.py which descends from 8820c87e8ae3 — the same parent our merge revision 190d6e98754b already chained from. That made them siblings and produced two migration heads, which the test_architecture_single_migration_head guard catches at quality-gates time. CI was tripping on the same check because migrate.py refused to apply with "Multiple head revisions are present", which cascaded into every DB-touching integration + E2E test. Re-point 190d6e98754b's main-side parent from 8820c87e8ae3 to r0s1t2u3v4w5 (which itself descends through 8820c87e8ae3 → 17423a1b551e → base). Graph converges to a single head; alembic history is linear-ish again. Verified locally: $ uv run alembic heads 190d6e98754b (head) $ make quality 4,438 passed / 14 skipped / 19 xfailed The commit message comment in the migration is updated to note that the main-side parent will move forward as new migrations land on main — each subsequent origin/main merge re-points this parent again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): implement Reporting client + wire sync end-to-end Builds the Query Reporting API client speculative-but-defensive against the /reporting/* surface we mapped via probing. Day-of-scope this fires real reports against FW; day-zero it raises ReportingScopeNotGranted cleanly on the 403. Code: - src/adapters/freewheel/_reporting.py: FreeWheelReportingClient with submit_job / get_job / wait_for_completion / fetch_results. JobSpec (Pydantic) serialises POST /reporting/jobs bodies. JobStatus parses enum-strings case-insensitively and clamps unknown values to UNKNOWN so a new FW status doesn't break the polling loop. JobState parses both snake_case and camelCase payloads defensively, preserves the raw dict for fields we don't yet know about. ColumnMap is a single tunable for FW's result column names — day-of-scope edit one place when we see the real column labels. - src/adapters/freewheel/reporting_sync.py: FreeWheelReportingSync.run() now actually orchestrates submit/poll/fetch/upsert. ForbiddenError caught once at the top and re-raised as ReportingScopeNotGranted so callers get a clean signal. Cache upsert via the existing FreeWheelPlacementStatsRepository.bulk_upsert. - src/adapters/freewheel/_transport.py: added post_json + delete_json helpers (existing v3 surface only had post_xml + delete_xml). - src/admin/blueprints/adapters.py: new POST endpoint /api/tenant/<id>/adapters/freewheel/sync-reporting — admin-only, same shape as sync-inventory. Returns 503 with scope_pending=true when the upstream IAM-denies us so the UI can render the right copy. - templates/adapters/freewheel/connection_config.html: added "Sync Reporting Now" button + syncFreeWheelReporting() JS. UI lives between Inventory Sync and API Permissions so the operator's first three buttons match the natural flow: connect → inventory → reporting. Tests (tests/unit/test_freewheel_reporting_client.py — 27 new): - JobSpec serialisation (minimum + with filters) - JobStatus parsing (5 known values + unknown clamps to UNKNOWN + None) - JobState parsing (snake_case + camelCase + preserves raw + error_message) - parse_row (default map, string-numbers coercion, missing fields, garbage input, custom ColumnMap remap, as_of fallback to now) - submit_job round-trips the request body - wait_for_completion (immediate-terminal, polls PENDING→RUNNING→COMPLETED, timeout raises with last state, CANCELED is terminal) - fetch_results (inline rows, alternate keys 'rows'/'results'/'data', raises when job not complete) Cache test updated (tests/unit/test_freewheel_reporting_cache.py): the scope-handling tests now patch transport.post_json to raise FreeWheelForbiddenError, matching production behaviour rather than the old "raises unconditionally" stub. Live verified (against Talpa, user 35696): sync.run() correctly attempts POST /reporting/jobs, FW returns 403, our code catches it once and raises ReportingScopeNotGranted with the friendly message. Day-of- scope the same code path will fire and run the actual report — if FW's request shape differs from our spec, ColumnMap + JobSpec serialisation have explicit edit points. Docs: README live-coverage matrix updated: get_media_buy_delivery / get_packages_snapshot: ⏳ stub → 🟡 wired (reads cache; populated by sync once scope lands) make quality: 4,465 passed / 14 skipped / 19 xfailed (gained 27 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(freewheel): stop false-zero delivery webhooks when reporting cache is empty Brian flagged that the DeliveryWebhookScheduler runs hourly automatically, calls adapter.get_media_buy_delivery() per active media buy, and fires webhooks to buyers — but our FW adapter was returning zero-delivery responses when the placement_stats cache was empty (which is the steady state today, since the Reporting API scope is still pending). Buyers polling AdCP or subscribing to delivery webhooks would see fake "delivering=0 impressions" signals every hour, which is misleading. Fix: introduce a soft-error signal that distinguishes "integration healthy, no data YET" from "integration broken": - New AdServerAdapter base exception DeliveryDataUnavailable. Adapters raise it when they have no data to report but nothing is actually wrong upstream — typical causes: cache not yet populated, upstream reporting scope still pending. Shareable across adapters. - FreeWheelAdapter.get_media_buy_delivery now raises DeliveryDataUnavailable when the placement_stats cache has no rows for the requested insertion order, instead of returning zeros via the base _empty_delivery_response helper. - _get_media_buy_delivery_impl catches DeliveryDataUnavailable separately from the generic adapter-error catch-all. The clean error surfaces as a GetMediaBuyDeliveryResponse with errors=[Error( code="data_unavailable")] — no audit log, no warning-level noise, just an info-level "data not yet available" log. - DeliveryWebhookScheduler's soft-skip set widened from just {"media_buy_status_excluded"} to also include "data_unavailable" — same info-level skip, no false-zero webhook fires. Test (tests/unit/test_freewheel_reporting_cache.py): the empty-cache test flipped from "returns zero response" to "raises DeliveryDataUnavailable with media_buy_id set". This pins the contract that AdCP layer + scheduler depend on. Defer-list captured in expanded comment on #382: the proper fix for this whole area (per-adapter buttons → shared scheduler + uniform adapter contract + /admin/scheduling page) is significant scope and should be its own PR after #381 merges. This change is the minimum-surgical fix to stop bad signals today. make quality: 4,465 passed / 14 skipped / 19 xfailed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel ui): proactive scope-pending banner on adapter settings page Operators were finding out about missing FW scope two ways: by clicking "Check API Permissions" (which they wouldn't do unprompted), or by a buyer reporting "I'm seeing data_unavailable for delivery." Both are surprises. Surface the state on page load instead. Adds a banner above the FW configuration form that auto-runs check_permissions and renders one of three states: - (no banner): fully_operational, nothing surprising. - (warn, amber): reporting scope is denied. Banner explains buyers will see data_unavailable until granted; other features work normally. Reporting is technically a "nice-to-have" probe in our report shape, but its absence has real operator-visible consequences worth flagging. - (error, red): a required probe failed. Banner lists the missing features. Other denied nice-to-haves alone (targeting_profiles, audiences, webhooks) don't trigger the banner — they're true optionals and would become noise. They remain visible in "Check API Permissions" for operators who care. Banner has a "See full permissions checklist →" link that scrolls down and triggers the existing on-demand probe so the operator sees the full per-endpoint breakdown. Quietly no-ops on auth-level failure / no creds (those are surfaced by the credentials section already) and on transient probe failures (page should still load). make quality: 4,465 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(freewheel): wire creative trafficking + stale-cache freshness banner Two gaps closed in one commit so the FW adapter is feature-complete for the buyer-facing flow (create buy → traffic creatives → see delivery) before #381 merges. ### Creative trafficking — end-to-end Earlier we proved /services/v4/creative_instances POST works (via Brian's docs-read showing ad_id = ad_unit_node_id). Now actually wired: - src/adapters/freewheel/_creatives.py: added create_creative, delete_creative, create_creative_instance, delete_creative_instance. Two wire-shape gotchas captured (verified live against Talpa): * POST creative_resources: body must be wrapped under {"creative": {...}}; flat body returns 400 "Creative Node is missing". Response is doubly-wrapped: {"data": {"creative": {...}}}. * POST creative_instances: ``ad_id`` is FW's param name but its docs say "The Ad Unit Node ID to link Creative." Response auto- populates placement_id (FW derives it from the ad_unit_node). - src/adapters/freewheel/adapter.py: * add_creative_assets: POSTs one creative_resource per AdCP asset, stamps the AdCP id onto FW external_id for lineage, returns AssetStatus(creative_id=<FW id as string>, status="approved"). * associate_creatives: looks up ad_unit_node_ids per placement from the inventory cache, POSTs one creative_instance per (node, creative) pair. Per-binding result rows so callers see partial successes. Skipped placements (no cached ad_unit_nodes → run inventory sync first) get a clear message rather than silent failures. Live cycle verified end-to-end against Talpa: create_creative → create_creative_instance → delete_creative_instance → delete_creative, all clean. ### Stale-cache freshness banner Two new repository methods (latest_sync_at) on the inventory + placement- stats repos. New GET /api/tenant/<id>/adapters/freewheel/cache-freshness endpoint returns last_synced_at + age_seconds + stale flag + threshold for both caches. Threshold defaults: 24h inventory, 2h reporting. UI: second banner above the FW config form (alongside the scope-pending banner). Renders only when something needs flagging: - blue (info): cache never synced — onboarding gap - amber (warn): cache stale — sync probably broken - no banner: everything fresh ### Test infra cleanup Extracted tests/helpers/freewheel_adapter_patches.py::patch_freewheel_db so the same FreeWheelInventoryRepository + get_db_session monkeypatch block isn't duplicated across test modules. Both test_freewheel_adapter.py and test_freewheel_creative_trafficking.py now use the helper. Duplication guard happy again. ### Tests - tests/unit/test_freewheel_creatives.py: +4 cases pinning the write surface (wrapped POST body, ad_id semantics, DELETE paths). - tests/unit/test_freewheel_creative_trafficking.py: 9 cases — dry-run + live + fan-out + partial-failure + missing-inventory-skip. - All existing tests still pass via the shared helper. make quality: 4,477 passed (gained 12). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prebid#409) * fix(audit): stop double-encoding audit_logs.details + repair migration log_security_violation passed details=json.dumps({...}) into the JSONB column. JSONType.process_bind_param then serialized that already-stringified JSON again, so the row landed with a JSONB value of type 'string' instead of 'object'. Strict readers (notably tenant_export.py) refuse those rows, which blocked tenant exports on every tenant that had ever had a security violation logged — ~1,272 rows across multiple production tenants. Fix: - src/core/audit_logger.py:282 — pass the dict directly; JSONType handles serialization. One-line change. - alembic migration s1t2u3v4w5x6 — repair existing rows with UPDATE audit_logs SET details = ((details::jsonb) #>> '{}')::jsonb WHERE details IS NOT NULL AND jsonb_typeof(details::jsonb) = 'string'; Idempotent: re-running matches zero rows on a clean DB. Downgrade is intentionally unsupported (re-encoding would re-introduce the bug); raises NotImplementedError with an explanation rather than silently corrupting. - tests/integration/test_audit_logger_details_shape.py — regression test asserting log_security_violation persists details as a JSONB object, not a JSON string. Checks both the ORM read (dict) and Postgres jsonb_typeof = 'object'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(migration): make s1t2u3v4w5x6 downgrade a true no-op The previous downgrade raised NotImplementedError to refuse re-corrupting repaired rows, but that broke test_managed_tenant_migrations_roundtrip which drives the chain backward to verify reversibility. Replace the raise with a SQL NOTICE. The body stays non-empty (migration- completeness guard happy), the data fix stays in place on downgrade (repaired rows are schema-compatible with all prior revisions), and the roundtrip test can step through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: project wholesale products from inventory bundles * fix: resolve inventory analytics migration head * fix: separate wholesale bundles from brief products * fix: align product tests with wholesale bundle discovery * fix: align sandbox product tests with brief discovery * fix: align mcp wholesale roundtrip fixture
* fix: canonicalize wholesale creative format refs * fix: accept canonical reference creative agent alias
…#684) * fix: clarify wholesale forecast and pricing metadata contract * fix: remove wholesale system metadata inputs
…d#687) * feat: auto-provision default GAM advertiser on tenant provision When provisioning a GAM-backed managed tenant without an explicit default_gam_advertiser_id, the provision endpoint now automatically calls ensure to create an "Interchange - Default" advertiser in GAM and records its ID on the tenant. Without a default advertiser, the buyer-advertiser routing chain raises TENANT_NOT_ACTIVATED for any media buy that lacks an explicit mapping, making the tenant unable to receive orders. This makes GAM tenants immediately operational without a separate setup step. The auto-provision is best-effort: failures are logged but do not fail the provision response. Callers can still set default_gam_advertiser_id explicitly on the request to opt out of the auto-create (e.g. when they already know the desired advertiser ID). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add push_notification_config to GetProductsRequest per AdCP spec The live AdCP spec added push_notification_config to get-products-request.json but the installed adcp library has not caught up. Adding it to our subclass so the schema alignment test passes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: suppress unreleased-fix CVEs and check cache before GAM advertiser provision Security audit: aiohttp GHSA-jg22-mg44-37j8/GHSA-hg6j-4rv6-33pg, authlib PYSEC-2026-188, and PyJWT PYSEC-2026-175/177/178/179 have no released fix versions yet. Added to the ignore-vulns list with comments; remove each entry when the upstream fix ships. GAM provisioning: _auto_provision_gam_default_advertiser now checks the local sync cache for an existing advertiser with the default name rather than calling gam_ensure_advertiser_companyservice unconditionally. If nothing is cached, it skips and lets the operator set the default via the ensure endpoint. This avoids creating GAM advertisers without explicit operator intent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add provision_default_resources flag for default advertiser setup Adds an explicit opt-in flag to ProvisionTenantRequest. When provision_default_resources=True, the provision endpoint ensures adapter-specific default resources exist: for GAM, it checks the local advertiser cache first and calls gam_ensure_advertiser_companyservice if nothing is cached. Defaults to False so callers must opt in. Reverts push_notification_config from GetProductsRequest subclass (waiting for adcp SDK to expose it on the library type) and tracks it in KNOWN_SCHEMA_LIBRARY_MISMATCHES instead. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ions (prebid#688) * fix: defer SpringServe demand_partner_id check to buyer-facing operations The sync orchestrator constructs adapters using a stub principal without a demand_partner_id, causing every SpringServe inventory/reporting sync to fail with a ValueError at adapter construction time — before any sync work runs. The demand_partner_id is only needed for campaign, demand-tag, and creative creation, not for read-only sync paths. Moves the guard out of __init__ into _require_demand_partner_id(), called at the entry point of each buyer-facing mutating method. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: don't validate demand_partner_id inside dry-run code path _demand_tag_kwargs was calling _require_demand_partner_id(), but it is invoked from the dry-run block in create_media_buy where no demand_partner_id is needed — the kwargs are only logged, not sent to the API. In live mode, _require_demand_partner_id() is already called at the top of the live path before the loop that calls _demand_tag_kwargs, so the live-mode validation was redundant there too. Use self.demand_partner_id or 0 in _demand_tag_kwargs (0 is a harmless placeholder in dry-run; a valid ID in live mode after the caller has already validated). Adds a test that dry-run create_media_buy succeeds with no demand_partner_id configured. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: ruff format test file * fix: add ag-ui-protocol and grpcio to security scan quarantine list, acknowledge push_notification_config spec lag Security audit: uv-secure crashes with an empty unhandled exception on ag-ui-protocol and grpcio when fetching their PyPI metadata. Added both to the lockfile-strip quarantine list following the same pattern as the existing mistralai/types-psycopg2/charset-normalizer entries. Schema alignment: AdCP spec now includes push_notification_config on GetProductsRequest but the installed adcp Python library does not yet expose it. Added to KNOWN_SCHEMA_LIBRARY_MISMATCHES following the same pattern as the existing get-media-buy-delivery-request entries. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Harden inventory sync stale-row recovery and prevent destructive full-sync cache clears before successful discovery.
fix: chunk truncated GAM pricing reports
fix: tolerate single-placement GAM pricing caps
fix: Handle inapplicable GAM derived sync status
…rebid#703) * feat: FreeWheel API-Access client_credentials auth + sandbox support Adds a third FreeWheel auth mode (OAuth2 client_credentials) so the adapter can use API-Access app credentials, plus a sandbox environment/host. Proven live against api.sandbox.freewheel.tv. - _transport.py: client_credentials grant minted from a decoupled token_url (API-Access token service is a different host than the data plane) via HTTP Basic, wired into BearerTokenCache.mint_fn so it auto-refreshes; 5-min leeway for ~1h tokens. Both minters share _mint_via_oauth (DRY). Adds SANDBOX_BASE_URL. - schemas.py: client_id / client_secret (Fernet-encrypted) / token_url (https-enforced) fields; sandbox env + host; updated credential validation. - client.py / adapter.py: thread the new params through; adapter reads them from config and selects the host by environment. - create_media_buy: set external_id on the IO (po_number) and each placement (package_id) for AdCP->FreeWheel lineage — validated against the sandbox. - admin: connection_config.html gets client_id/secret/token_url inputs + a Sandbox option; the test-connection route + adapter_connection_tester probe via an inventory read for client_credentials (token_info 401s for API-Access tokens); auth-mode helpers handle the third mode. Tests: client_credentials mint, sandbox host, secret encryption, https enforcement, external_id lineage, auth-mode clearing, and the tester's client_credentials branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): bump aiohttp, authlib, pyjwt for security advisories Clears the uv-secure pre-push gate (7 CVEs): - aiohttp 3.13.5 -> 3.14.1 (GHSA-jg22-mg44-37j8, GHSA-hg6j-4rv6-33pg) - authlib 1.6.11 -> 1.6.12 (PYSEC-2026-188); capped <1.7 to stay on the 1.6.x patch line and avoid the joserfc-backed 1.7 refactor - pyjwt 2.12.1 -> 2.13.0 (PYSEC-2026-175/177/178/179) Per-library breaking-change audit: aiohttp + authlib changes are server/grant -side and don't touch our client usage (risk none). pyjwt 2.13 now rejects empty HMAC keys; production is unaffected (decode uses verify_signature=False), but the id-token-fallback test built a fixture token with an empty key — fixed to use a throwaway non-empty key (value is irrelevant; decoded unverified). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): bump adcp 6.3.0b7 -> 6.3.0b8 Fixes the pre-existing GetProductsRequest schema-alignment failure (prebid#690): b8's GetProductsRequest includes push_notification_config, matching the live AdCP JSON schema. b8 also evolves the generated types; reconciled three tests with no behavior/wire change on our side: - StrEnum-based enums: str(enum) now yields the value (not the "CreativeSortField.created_date" repr), and a StrEnum member is also a str instance. Updated test_query_summary_sort_applied_serializes_enum_values and test_model_dump_mode_override to assert enum identity / value rather than the old impl details. JSON wire output (model_dump mode="json") is unchanged. - New Product fields sponsored_placement_types + social_placement_surfaces (adcp 6.3) are inherited from the library Product and not persisted — added to the schema-database-mapping computed_fields allowlist, matching the existing 3.9/3.10/3.12/4.4 entries. b9 was rejected: no dependency solution on the Python 3.14 resolution split (our requires-python is >=3.13). b8 resolves cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (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.
Service accounts without order-approval permission in GAM were causing
media buy creation to fail entirely. The order and line items were
created successfully in GAM but the PERMISSION_DENIED fault on
ApproveOrders was surfaced as a hard failure.
permission failures from transient NO_FORECAST_YET retries
is detected, instead of silently returning False
status-polling instead of approval-polling
background poller handles eventual activation
get_order_status() every 30 s for up to 24 h, activates the media buy
and updates gam_orders.status to APPROVED once a human approves in GAM
catch for CommonError.NOT_FOUND on advertiserId and raise AdCPAdapterError
with an actionable re-provision message