Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MIGRATION_v9_to_v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk
| AdCP 3.3.0-beta.1 adds account requests, media-buy issues and reporting subscriptions | New tasks are wired through `ADCPClient` / `ADCPMultiAgentClient`, `ADCPHandler` (each returns `not_supported()` until overridden), the MCP and A2A adapters, the MCP tool list and the CLI: `request_account` (experimental `account.requests`; it opens, matches or, with `account_id`, changes an account, guarded by `revision`), `report_media_buy_issue` and `get_media_buy_issue`. `RequestAccountRequest` / `Response`, `ReportMediaBuyIssueRequest` / `Response` and `GetMediaBuyIssueRequest` / `Response` are exported from `adcp.types`. `list_accounts` gains `include_requests`, `request_ids` and `requests_pagination`, returning `account_requests[]` and `account_requests_pagination`. Experimental principal-level `reporting_subscriptions` land on `sync_principal` / `get_principal`, with `subscription_coverage[]` on `get_reporting_status`, `configuration_tasks[]` on `media_buy.reporting_delivery`, the `ReportingDimension` enum and the `principal.changed` reason `reporting_subscription_changed`. `sync_accounts` is unchanged and stays supported. Generated class names that the generator numbers can renumber with the bundle; import the semantic names from `adcp.types` rather than a numbered class from a domain module. |
| AdCP 3.3.0-beta.2 withdraws definition pins, purchase rights and installment selection | adcontextprotocol/adcp#8537 moves all three to 4.0, so their generated types leave `adcp.types.domains.*`, `adcp.types._generated` and `adcp.types.error_details`: `DefinitionPin*`, `DefinitionPinReferenceKind`, `ReferenceDefinitionChangedDetails` (and `ErrorCode.REFERENCE_DEFINITION_CHANGED`), `DriftedPin`, `RightsScope`, `InstallmentChangeSet`, `commercial_terms.definition_pins`, `purchases[].rights` / `installments`, `installment_changes` and the `definition_changed` refinement reason. `TestControllerStore.definition_pin_probe` and the `definition_pin_probe` controller scenario are removed. Delete code that builds or reads them; a seller stops listing `media_buy.purchase_rights` and `media_buy.installment_selection` in `experimental_features`. |
| AdCP 3.3.0-beta.2 adds separate-account outcomes, seller offers and measurement provenance | `RequestAccountRequest.allow_separate_account` (default `false`) lets a change request resolve as `created` (a new account carrying the change) or `matched` (an account the caller already holds), and the new correctable error code `ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT` reports a change the seller would take only on a new account. Experimental seller offers add `proposal_initiator`, `offer_reason` on `list_proposals` rows and the `proposal.offered` notification (`ProposalOfferedWebhook`); experimental measurement provenance adds `CollectionMechanism`, `counting_mechanisms` and `vendor_metric_provenance[]` (`VendorMetricProvenance`). The reporting-subscription fields settled after beta.1 (`standard_dimensions`, `unmapped_dimensions`, `inactive_intervals`, `prior_versions[].configuration`, per-offering `offering_id`, `previous_timezone`) are now generated. `principal_kind` and `expected_principal_kind` are deprecated. `NotificationConfig.product_payload_view` loses its schema default: it is `None` when unset, and a product subscription's dump no longer adds `product_payload_view: "legacy"` (absent means legacy); read `config.product_payload_view or "legacy"`. These are generated types, imported from their domain packages (for example `adcp.types.domains.core.ProposalOfferedWebhook`); no task was added. |
| AdCP 3.3.0-beta.3 composes the protocol envelope on every task response | adcontextprotocol/adcp#8547: every response root composes `core/protocol-envelope.json`, which composes `core/version-envelope.json`, so generated responses inherit `ProtocolEnvelope` alone (no separate `AdcpVersionEnvelope` base) and the local copies of envelope fields are gone. Submitted, working and input-required bodies compose `core/task-submitted.json` / `task-working.json` / `task-input-required.json`: a submitted arm still pins `status` to `submitted` and requires `task_id`. `task_id` (non-empty) and the task-level `message` (at most 2,000 characters) each have one definition (`core/task-id.json`, `core/task-message.json`); fields that refer to them stay typed `str` with those constraints, so no caller has to wrap a value. `TaskId`, `TaskMessage`, `TaskSubmitted`, `TaskWorking` and `TaskInputRequired` are generated. Consequences for handlers: `list_products` and `decline_proposals` now carry `status` (the server stamps `completed`; the 3.3 → 3.2 conversion still drops it for 3.2 callers); an absent `replayed` reads as `false` and `replayed: false` is valid; and on governance responses `status` is the task status, so a `check_governance` result names its decision in `verdict` and `report_plan_outcome` in `outcome_state`, never in `status`. `create_mcp_webhook_payload` gives a `result` without `status` the webhook's task status, and replaces a model result's defaulted envelope status with it, so a failed task's result never reads `completed`. |
| AdCP 3.3.0-beta.3 composes the protocol envelope on every task response | adcontextprotocol/adcp#8547: every response root composes `core/protocol-envelope.json`, which composes `core/version-envelope.json`, so generated responses inherit `ProtocolEnvelope` alone (no separate `AdcpVersionEnvelope` base) and the local copies of envelope fields are gone. Submitted, working and input-required bodies compose `core/task-submitted.json` / `task-working.json` / `task-input-required.json`: a submitted arm still pins `status` to `submitted` and requires `task_id`. `task_id` (non-empty) and the task-level `message` (at most 2,000 characters) each have one definition (`core/task-id.json`, `core/task-message.json`); fields that refer to them stay typed `str` with those constraints, so no caller has to wrap a value. `TaskId`, `TaskMessage`, `TaskSubmitted`, `TaskWorking` and `TaskInputRequired` are generated. Consequences for handlers: `list_products` and `decline_proposals` now carry `status` (the server stamps `completed`; the 3.3 → 3.2 conversion drops it for 3.2 callers, as a recorded loss, and the 3.2 → 3.3 conversion supplies it). Whether a release carries `status` on those two tools is read from that release's own response schema (`uses_task_status_envelope(tool, version)` asks whether it composes `core/protocol-envelope.json`), not from its version string, so a 3.2.x release that later composes the envelope needs no SDK change; under a release that does not, the typed model's defaulted `completed` is not sent (a closed 3.2 `list_products` root refuses it) and nothing is stripped for any other tool; an absent `replayed` reads as `false` and `replayed: false` is valid; and on governance responses `status` is the task status, so a `check_governance` result names its decision in `verdict` and `report_plan_outcome` in `outcome_state`, never in `status`. `create_mcp_webhook_payload` gives a `result` without `status` the webhook's task status, and replaces a model result's defaulted envelope status with it, so a failed task's result never reads `completed`. |
| AdCP 3.3.0-beta.3 adds live creative updates, account-request money terms and reporting-subscription fields | Experimental `creative.live_updates` (`LiveUpdatePolicy`, `CreativeStagedUpdate`, `creative.staged_update_changed` and `CreativeStagedUpdateChangedWebhook`, error code `CREATIVE_LIVE_UPDATE_UNSUPPORTED` with `CreativeLiveUpdateUnsupportedDetails`). `request_account` outcomes carry `account` money terms on `matched` / `created`, `effective_at` on an `updated` money change, and `not_carried_over`. Reporting subscriptions gain `destination_overrides[]`, the paged readback (`prior_versions_count`, `accounts_with_exceptions_count`, `accounts_unauthorized_count`, `get_principal` `reporting_subscription_page`), `authorization.regranted_at`, `flight_history[]` and `cancellation_effective_at` on media buys, `available_from` / `withdrawn_at` on offerings and `stub` on billing obligations. `comply_test_controller` adds the `reporting_subscription_probe` scenario: override `TestControllerStore.reporting_subscription_probe` to serve it. Generated renames: `PriorVersion` is `ReportingSubscriptionPriorVersion`, `AccountsWithException` is `ReportingSubscriptionAccountExceptions`, and `AccountsUnauthorizedItem` is `ReportingSubscriptionUnauthorizedAccount`. |
| The reporting-subscription surface is served from 3.3.0-beta.3 on, unprojected | The beta.1 / beta.2 field projections are gone, with the preview module that patched the fields onto the models: `project_offering`, `project_reporting_delivery`, `project_subscription_state`, `project_subscription_coverage` and `serves_settled_subscription_fields` are removed from `adcp.reporting.subscriptions`, and `ReportingSubscriptionEngine.subscription_coverage()` no longer takes `adcp_version`. One predicate, `adcp.reporting.subscriptions.serves_subscriptions(adcp_version)` (from `SUBSCRIPTIONS_MIN_ADCP_VERSION`, `3.3.0-beta.3`), gates the whole surface: capabilities, `get_reporting_status` coverage (including `status: "inactive"` with `deactivated_at`) and the principal readback. A caller served an earlier release gets none of it. The extensible `reporting-dimension` vocabulary accepts unknown values on selectors, offerings and the capability block. |
| Reporting-subscription registration follows the 3.3.0-beta.3 rules | A caller may hold several pacing or analytics subscriptions per account and purpose (the lowest `subscription_id` owes a shared offering; the others record a package-level `overridden` exception), while billing stays one per account at a scope level and must use `match: exact` with `dimensions`. Membership edits (`scope.account_ids`, `exclude_account_ids`) no longer need a new `subscription_version`, and `client.reporting.subscribe` no longer bumps the version for them. `destination_overrides` must name existing destinations. The readback lists 16 prior versions and 1,000 accounts inline with `*_count` totals; page the rest with `get_principal` `reporting_subscription_page`. A partial grant refuses an unlisted reporting task with `SCOPE_INSUFFICIENT`. `AccountReady` gains `resolution`, `billing`, `billing_entity`, `payment_terms`, `effective_at` and `not_carried_over`. |
Expand Down
42 changes: 39 additions & 3 deletions src/adcp/compat/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,7 @@ def _score(schema: dict[str, Any], value: Any, root: dict[str, Any]) -> int:
else (
"string"
if isinstance(value, str)
else "number"
if isinstance(value, (int, float))
else "object"
else "number" if isinstance(value, (int, float)) else "object"
)
)
)
Expand Down Expand Up @@ -1095,6 +1093,42 @@ def log_conversion_summary(
)


def _convert_compact_status(
tool: str,
result: dict[str, Any],
source: str,
target: str,
step: int,
losses: list[ConversionLoss],
) -> None:
"""Move the task ``status`` of a compact response across the 3.2 / 3.3 line.

``list_products`` and a synchronous ``decline_proposals`` have no ``status``
under 3.2.x (the closed roots predate the protocol envelope) and require it
under 3.3, which composes the envelope. Which side has it is read from each
release's own schema (:func:`uses_task_status_envelope`), so only these
tools move and a 3.2.x release that composes the envelope needs no change.
Going up supplies the completed status a synchronous result implies; going
down drops it, as a loss, rather than send a value the 3.2 root refuses. A
``submitted`` response keeps its status, which the older contract permits.
"""
from adcp.validation._response_envelope import (
_NATIVE_COMPACT_ENVELOPE_TOOLS,
uses_task_status_envelope,
)

if tool not in _NATIVE_COMPACT_ENVELOPE_TOOLS:
return
has_status = uses_task_status_envelope(tool, source)
wants_status = uses_task_status_envelope(tool, target)
if step > 0 and wants_status and not has_status:
if "status" not in result and "task_id" not in result:
result["status"] = "completed"
elif step < 0 and has_status and not wants_status and result.get("status") == "completed":
del result["status"]
losses.append(ConversionLoss("/status", source, target))


def _convert_payload(
tool: str,
payload: dict[str, Any],
Expand Down Expand Up @@ -1167,6 +1201,8 @@ def _convert_payload(
"account" if request is None or request.get("account") is not None else "public"
)
result.setdefault("cache_scope", scope)
if direction != "request":
_convert_compact_status(tool, result, source, target, step, losses)
if step > 0 and _carries_interim_ext(result):
_hoist_interim_ext(result, target_schema, target_schema)
if step < 0:
Expand Down
13 changes: 8 additions & 5 deletions src/adcp/server/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,16 @@ def _normalize_response_envelope(
# outcome discriminator.
if "outcome" not in result and "products" in result:
result["outcome"] = "listed"
if (
uses_task_status_envelope(method_name, adcp_version)
and "status" not in result
and "task_id" not in result
):
status_envelope = uses_task_status_envelope(method_name, adcp_version)
if status_envelope and "status" not in result and "task_id" not in result:
if not (is_sync_media_buy_success and not _is_adcp_31_or_newer(adcp_version)):
result["status"] = "completed"
elif not status_envelope and result.get("status") == "completed":
# The typed response models default ``status`` to ``completed`` (3.3
# requires it). The served release has none for this tool, so the
# model's default must not reach a closed 3.2.x root. Only the implied
# synchronous value goes; a submitted arm keeps its status.
del result["status"]
if (
method_name in {"get_products", "get_signals"}
and "cache_scope" not in result
Expand Down
23 changes: 20 additions & 3 deletions src/adcp/validation/_response_envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
grafting ``completed`` onto either makes the result fail the pinned response
schema. From 3.3.0-beta.3 every task response and async status payload
composes ``core/protocol-envelope.json`` (adcontextprotocol/adcp#8547), whose
``status`` is required, so the exception ends. Keep the behavioral tests in
``test_compact_lifecycle_matrix.py`` aligned when adding another exception.
``status`` is required, so the exception ends.

The served release's own response schema decides, not its version string, so a
3.2.x release that later composes the envelope needs no code change (as in the
TypeScript SDK, adcontextprotocol/adcp-client#3493). The version comparison is
only the answer for a release that ships no schema for the tool. Keep the
behavioral tests in ``test_compact_lifecycle_matrix.py`` and
``test_response_status_gating.py`` aligned when adding another exception.
"""

from __future__ import annotations
Expand All @@ -26,7 +32,11 @@ def uses_task_status_envelope(tool_name: str, version: str | None = None) -> boo
"""Return whether a tool response uses the task ``status`` envelope at ``version``.

``version`` is the release the response is shaped for; ``None`` is the
SDK's pin. An unparseable version keeps the compact exception.
SDK's pin. Only the compact tools in ``_NATIVE_COMPACT_ENVELOPE_TOOLS`` ever
answer ``False``, and only for a release whose response schema for them does
not compose ``core/protocol-envelope.json``. A release with no schema for the
tool, or an unparseable version, keeps the compact exception unless the
version is at or past :data:`ENVELOPE_ON_EVERY_RESPONSE_FROM`.
"""
if tool_name not in _NATIVE_COMPACT_ENVELOPE_TOOLS:
return True
Expand All @@ -35,7 +45,14 @@ def uses_task_status_envelope(tool_name: str, version: str | None = None) -> boo
is_adcp_version_at_least,
resolve_adcp_version_alias,
)
from adcp.validation.schema_loader import response_composes_protocol_envelope

try:
composed = response_composes_protocol_envelope(tool_name, version=version)
except ValueError:
composed = None
if composed is not None:
return composed
release = _read_packaged_version() if version is None else version
try:
return is_adcp_version_at_least(
Expand Down
55 changes: 55 additions & 0 deletions src/adcp/validation/schema_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ def __init__(self, root: _SchemaRoot, bundle_key: str) -> None:
#: ``MAJOR.MINOR`` while its documents reference the exact release,
#: for example ``/schemas/3.2.1/`` inside the ``3.2`` cache.
self.path_versions: tuple[str, ...] = (bundle_key,)
#: ``tool_name`` -> whether that release's sync response schema composes
#: ``core/protocol-envelope.json``. Answered from the schema document.
self.composes_envelope: dict[str, bool] = {}


def _bundle_path_versions(root: _SchemaRoot, bundle_key: str) -> tuple[str, ...]:
Expand Down Expand Up @@ -1012,6 +1015,58 @@ def get_schema(
)


def _is_protocol_envelope_arm(arm: Any) -> bool:
"""Whether a root ``allOf`` arm is ``core/protocol-envelope.json``.

Bundled schemas arrive with the reference already inlined, so the arm is
recognised by what the envelope contributes: a required ``status``. An
unresolved reference to the envelope document counts too.
"""
if not isinstance(arm, dict):
return False
ref = arm.get("$ref")
if isinstance(ref, str):
return urldefrag(ref)[0].endswith("/core/protocol-envelope.json")
required = arm.get("required")
properties = arm.get("properties")
return (
isinstance(required, list)
and "status" in required
and isinstance(properties, dict)
and "status" in properties
)


def response_composes_protocol_envelope(
tool_name: str,
*,
version: str | None = None,
) -> bool | None:
"""Whether the release's own response schema for ``tool_name`` composes the envelope.

The envelope (``core/protocol-envelope.json``) requires ``status``. A release
schema that lists it in the root ``allOf`` therefore requires ``status`` on a
response; one that does not either forbids it (a closed 3.2.x
``list_products`` root) or does not define it for the success arm. The
release's schema is the authority, not its version string, so a maintenance
release that composes the envelope changes the answer with no code change.
Returns ``None`` when no schema for the tool ships for ``version``.
"""
state = _ensure_state(version)
if state is None:
return None
cached = state.composes_envelope.get(tool_name)
if cached is not None:
return cached
schema = get_schema(tool_name, "sync", version=version)
if schema is None:
return None
arms = schema.get("allOf")
composed = isinstance(arms, list) and any(_is_protocol_envelope_arm(arm) for arm in arms)
state.composes_envelope[tool_name] = composed
return composed


def get_named_schema_document(
relative_path: str,
*,
Expand Down
Loading
Loading