Skip to content

feat(server): negotiate media buy version handling and update actions#871

Merged
bokelley merged 3 commits into
mainfrom
adapt-responses-by-version
May 26, 2026
Merged

feat(server): negotiate media buy version handling and update actions#871
bokelley merged 3 commits into
mainfrom
adapt-responses-by-version

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

  • widen media-buy MCP output schemas for negotiated 3.0 status compatibility and propagate resolved AdCP version through MCP/A2A dispatch context
  • add a dynamic DecisioningPlatform capabilities hook with guarded merge behavior for portfolio/status extensions
  • add update media buy mutation decomposition helpers, allowed-action checks, docs, and hello seller example enforcement

Addresses #866, #867, #868.

Validation

  • PYTHONPATH=src ruff check src/adcp/server src/adcp/decisioning tests/test_tools_list_output_schema.py tests/test_server_dx.py tests/test_dispatcher_version_routing.py tests/test_decisioning_capabilities_projection.py tests/test_a2a_server.py tests/test_update_media_buy_decompose.py tests/test_hello_seller_integration.py examples/hello_seller.py
  • PYTHONPATH=src mypy src/adcp/server/ src/adcp/decisioning/handler.py src/adcp/decisioning/platform.py src/adcp/decisioning/dispatch.py src/adcp/decisioning/update_media_buy.py examples/hello_seller.py
  • PYTHONPATH=src pytest tests/test_tools_list_output_schema.py tests/test_server_dx.py::TestPydanticSchemas tests/test_dispatcher_version_routing.py tests/test_decisioning_capabilities_projection.py tests/test_a2a_server.py::test_a2a_omitted_version_keeps_current_media_buy_envelope tests/test_a2a_server.py::test_a2a_explicit_major_version_projects_30_media_buy_shape tests/test_update_media_buy_decompose.py tests/test_hello_seller_integration.py -q
  • PYTHONPATH=src pytest tests/test_mcp_middleware_composition.py tests/test_adcp_version_wire.py tests/test_dispatcher_version_routing.py tests/test_schema_validation_server.py tests/test_a2a_server.py::test_a2a_omitted_version_keeps_current_media_buy_envelope tests/test_a2a_server.py::test_a2a_explicit_major_version_projects_30_media_buy_shape tests/test_update_media_buy_decompose.py -q
  • pre-commit hooks during commit: black, ruff, mypy, bandit, whitespace/yaml/json/large-file/merge-conflict/case-conflict/private-key checks

Notes

  • Left automatic update-media-buy action enforcement out of dispatch; adopters can opt in using the new helper against their current buy state and available_actions[].

bokelley added 2 commits May 26, 2026 06:15
…rsion

# Conflicts:
#	src/adcp/decisioning/handler.py
#	src/adcp/decisioning/platform.py
#	tests/test_decisioning_capabilities_projection.py
@bokelley bokelley changed the title [codex] negotiate media buy version handling and update actions feat(server): negotiate media buy version handling and update actions May 26, 2026
@bokelley bokelley marked this pull request as ready for review May 26, 2026 10:35
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. resolved_adcp_version is added as a top-level RequestContext field outside the echo-prone metadata bucket, the widened MCP outputSchema matches the negotiated 3.0 status envelope, and the new update_media_buy decomposer's 21-key _ACTION_CANDIDATES, 4-value mode set, 7-value status union, and field-coverage sets are byte-for-byte aligned with schemas/cache/3.1.0-beta.3/ (ad-tech-protocol-expert: sound-with-caveats).

Things I checked

  • Largest-file rule. Read src/adcp/decisioning/update_media_buy.py (911 lines) and tests/test_update_media_buy_decompose.py (284 lines) in full. _compare_ordered / _ordered_value / _numeric_sum correctly fail-closed on mixed-type / non-finite values; _package_budget_reallocation exact-sum detection uses Decimal(str(value)) arithmetic so float reallocations stay safe.
  • Public-API audit. New adcp.decisioning exports (UpdateMediaBuyMutation, decompose_update_media_buy, disallowed_update_media_buy_mutations, is_update_media_buy_mutation_allowed, normalize_update_media_buy_allowed_actions, requested_update_media_buy_actions, SELF_SERVE_UPDATE_ACTION_MODES, UNKNOWN_UPDATE_ACTION) are purely additive. ToolContext.resolved_adcp_version: str | None = None and create_tool_caller's new default_unnegotiated_adcp_version kwarg both default to the prior behavior. feat(server): without ! is the right conventional-commit prefix. Helper additions are documented in docs/handler-authoring.md:711-770.
  • Credential-echo contract. dispatch.py:1175-1178 adds resolved_adcp_version alongside request_id / caller_identity / tenant_id, not inside metadata, so it bypasses neither _validate_ctx_metadata_credentials nor the clean_metadata filter — and inject_context() echoes from buyer-supplied params["context"], never from the RequestContext object. No _CREDENTIAL_SHAPED_KEY_SUFFIXES regression (security-reviewer: no High).
  • Wire-version inputs are bounded. detect_wire_version() constrains buyer-supplied versions to SUPPORTED_WIRE_VERSIONS; _normalize_a2a_parameters (a2a_server.py:152-163) correctly un-floats adcp_major_version to repair the protobuf Struct float quirk while leaving release-precision adcp_version precedence intact per version-envelope.json.
  • Test-plan honesty. Validation block lists ruff, mypy, and named pytest selectors; all three pre-commit checks are exercised. No unchecked manual verification boxes.

Follow-ups (non-blocking — file as issues)

  • Schema-widener pins anyOf[0] by position. _widen_media_buy_output_schema_for_legacy_statuses (src/adcp/server/mcp_tools.py:124-157) assumes the success arm is at index 0. If the Pydantic union ever reorders (codegen renumber, discriminator refactor), the widener no-ops silently and 3.0 buyers start failing outputSchema validation — or worse, clobbers the wrong arm's status literal. Walk the anyOf and widen the arm whose properties.status.const == "completed", or assert at module-load that exactly one matches.
  • UNKNOWN_UPDATE_ACTION bypass is a footgun for unmapped fields. _TOP_LEVEL_UNMAPPED_MUTATION_FIELDS = ("invoice_recipient", "push_notification_config", "reporting_webhook") at update_media_buy.py:80-84 emit action="unknown" and are silently skipped by disallowed_update_media_buy_mutations (line 357-366). An adopter relying solely on this helper to gate buyer updates would accept reporting_webhook: <attacker URL> even when available_actions doesn't list it — SSRF or invoice-redirection vectors if the adopter side-effects on those fields. Either add an include_unknown=True mode that surfaces these, or rewrite docs/handler-authoring.md:751-770 to call this out as a footgun rather than a feature.
  • Bare-string / missing-mode entries fail open. _action_mode_matches at update_media_buy.py:799-803 returns True whenever mode is missing on the allowed-action entry. Intentional for bare strings, but an adopter ingesting wire-shaped available_actions[] from an upstream platform that omits mode will silently allow non-self-serve actions through a sync handler. Fail-closed for mapping/model inputs, fail-open only for bare strings.
  • Curated field sets drift silently against the upstream schema. _TOP_LEVEL_KNOWN_MUTATION_FIELDS / _PACKAGE_KNOWN_MUTATION_FIELDS are hand-curated; new spec fields will fall into UNKNOWN_UPDATE_ACTION and bypass the gate. Add a one-shot test diffing these sets against UpdateMediaBuyRequest.model_fields and PackageUpdate.model_fields.
  • creatives: [] branch is dead under spec validation. package-update.json declares creatives: minItems: 1, so the remove_creative heuristic at update_media_buy.py:495 only fires when input validation is disabled. Either remove the branch and document that creative removal routes through creative_assignments, or document the validation-bypass assumption.
  • Forward default_unnegotiated_adcp_version through create_mcp_tools. Adopters wiring via the framework helper can't override the "3.0" legacy pin without reaching into create_tool_caller directly.

Minor nits (non-blocking)

  1. __all__ placement. is_update_media_buy_mutation_allowed and requested_update_media_buy_actions in src/adcp/decisioning/__init__.py are inserted next to unrelated neighbors — is_* between require_org_scope and mixed_registry, requested_* between resolve_time_budget and serve. The wider list isn't strictly alphabetical anyway, but these two stand out.
  2. targeting_overlay payload duplication. When the overlay carries frequency_cap plus other keys, update_media_buy.py:581-611 emits both update_targeting (with the full overlay in .after / .raw) and update_frequency_caps — audit consumers reading raw see the cap twice. Either strip frequency_cap from the targeting mutation's payload, or call this out in the dataclass docstring.
  3. Top-level canceled: False is silently dropped. It's in _TOP_LEVEL_KNOWN_MUTATION_FIELDS so it doesn't fall through to _unknown_top_level_fields, but the explicit is True checks don't pick it up. Document that uncancel isn't a supported action, or emit UNKNOWN_UPDATE_ACTION.

Safe to merge.

@bokelley bokelley merged commit 5a89d86 into main May 26, 2026
24 checks passed
@bokelley bokelley deleted the adapt-responses-by-version branch May 26, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant