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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Unreleased

### ⚠ BREAKING CHANGES

* **types:** `CreativeAsset.format_kind`, `Creative.format_kind`, and
`CreativeManifest.format_kind` now reject values outside `CanonicalFormatKind`.
Valid strings still normalize to enum members. Inputs remain strict, while
buyer manifest readback preserves future kinds through private tolerant views
in delivery, preview, build (including nested variants and async results), and
trusted-match offers. Direct `Creative` and `CreativeAsset` response fields
remain strict. Update stored and incoming creative kinds using the
[migration guide](docs/canonical-format-kinds-migration.md).
This closes [#1241](https://github.com/adcontextprotocol/adcp-client-python/issues/1241).

## [8.0.0-rc.2](https://github.com/adcontextprotocol/adcp-client-python/compare/v8.0.0-rc.1...v8.0.0-rc.2) (2026-09-29)


Expand Down
81 changes: 81 additions & 0 deletions docs/canonical-format-kinds-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Canonical creative format-kind validation

`CreativeAsset.format_kind`, `Creative.format_kind`, and
`CreativeManifest.format_kind` now reject strings outside `CanonicalFormatKind`.
This is a breaking change from releases through 8.0.0-rc.2, which accepted and
preserved arbitrary strings in these fields. It aligns their enum validation
with the pinned `core/canonical-format-kind.json` schema.

Recognized wire strings still normalize to enum members and serialize to their
original string values. The public type stubs and validation JSON Schema now
describe the same closed set. `CreativeAsset` and `Creative` still require a
non-null kind. `CreativeManifest` retains its optional `None` default for model
composition; a complete wire manifest must also satisfy the versioned schema's
identity and asset requirements.

## Updating callers

Use a recognized canonical kind when validating creative data:

```python
from adcp.types import CanonicalFormatKind, CreativeAsset

creative = CreativeAsset.model_validate(
{
"creative_id": "creative-1",
"name": "Product image",
"format_kind": "image",
"assets": {},
}
)
kind: CanonicalFormatKind = creative.format_kind
assert kind is CanonicalFormatKind.image
```

Validate stored values before upgrading workflows that read existing creatives.
Unknown values such as `"totally_bogus"` now raise Pydantic `ValidationError`
during construction, `model_validate`, and `model_validate_json`, including
nested creative and manifest input fields. Correct each value to the canonical kind
whose contract the creative satisfies. Applications receiving a kind introduced
by a newer protocol version need an SDK version that supports that kind.

Adopter-defined formats use the existing `custom` kind with a corresponding
format declaration's `format_shape` and `format_schema`. Use it when the creative
conforms to that custom contract, rather than as a fallback for unknown values.

The public input annotations are `CanonicalFormatKind` for `CreativeAsset` and
`Creative`, and `CanonicalFormatKind | None` for `CreativeManifest`. Remove
application branches that treat kinds validated by these types as arbitrary strings.

## Buyer manifest readback

The SDK preserves unknown `format_kind` strings when reading manifests returned
by another agent. Known kinds still normalize to enum members. This applies to
all manifest-bearing response paths:

| Response | Manifest path |
| --- | --- |
| Canonical and legacy creative delivery | `creatives[].variants[].manifest` |
| `LegacyPreviewCreativeResponse3` | `manifest` |
| `LegacyBuildCreativeResponse1` | `creative_manifest` |
| `LegacyBuildCreativeResponse3` | `creative_manifests[]` |
| `LegacyBuildCreativeResponse4` | `creatives[].variants[].creative_manifest` |
| Trusted-match router and provider responses | `offers[].creative_manifest` |

Completed async build and preview results follow the same rule, including the
webhook result wrapper. `DeliveryCreative.format_kind` also remains
`CanonicalFormatKind | str | None`. This is tolerant SDK response parsing; it
does not widen the versioned wire schema's enum.

Direct `Creative.format_kind` and `CreativeAsset.format_kind` fields remain
strict, including `ListCreativesResponse.creatives[].format_kind`. The tolerance
applies to response manifests, not to these directly embedded creative types.

Responses use private manifest views, with private enclosing variants and offers
where needed. These types are reachable through responses but are not exported
from `adcp.types`. The public and generated `CreativeManifest` types remain
strict inputs. To reuse a returned manifest as input, dump it and validate it
with `CreativeManifest.model_validate(returned_manifest.model_dump())`. An
unknown kind fails this validation; choose a supported kind or upgrade the SDK
before submitting it. Passing the tolerant instance directly also cannot bypass
the input validator.
156 changes: 146 additions & 10 deletions src/adcp/types/_forward_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,30 @@
from __future__ import annotations

import json
from copy import copy
from collections.abc import Callable
from copy import copy, deepcopy
from functools import partial
from types import GenericAlias
from typing import Annotated, Any, cast, get_args

from pydantic import (
BaseModel,
ConfigDict,
Field,
GetCoreSchemaHandler,
GetPydanticSchema,
SerializerFunctionWrapHandler,
ValidationError,
ValidatorFunctionWrapHandler,
create_model,
model_validator,
)
from pydantic.fields import FieldInfo
from pydantic.json_schema import SkipJsonSchema
from pydantic_core import CoreSchema, InitErrorDetails, core_schema

from adcp.types.aliases import FormatAssetUnion, GroupFormatAssetUnion, RepeatableAssetGroup
from adcp.types.base import AdCPBaseModel
from adcp.types.canonical_creative import PackageRequest as PublicPackageRequest
from adcp.types.canonical_creative import PackageUpdate as PublicPackageUpdate
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
Expand All @@ -65,16 +71,35 @@
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
PublisherDomain as BundledPublisherDomain,
)
from adcp.types.generated_poc.core.async_response_data import AdcpAsyncResponseData
from adcp.types.generated_poc.core.canonical_format_kind import CanonicalFormatKind
from adcp.types.generated_poc.core.canonical_product import PublisherDomain
from adcp.types.generated_poc.core.creative_manifest import CreativeManifest
from adcp.types.generated_poc.core.creative_variant import CreativeVariant
from adcp.types.generated_poc.core.format import Format
from adcp.types.generated_poc.core.mcp_webhook_payload import McpWebhookPayload
from adcp.types.generated_poc.core.media_buy_features import MediaBuyFeatures
from adcp.types.generated_poc.core.targeting import TargetingOverlay
from adcp.types.generated_poc.core.targeting_input import TargetingOverlayInput
from adcp.types.generated_poc.core.version_envelope import AdcpVersionEnvelope
from adcp.types.generated_poc.creative.get_creative_delivery_response import (
Creative as DeliveryCreative,
)
from adcp.types.generated_poc.creative.get_creative_delivery_response import (
GetCreativeDeliveryResponse,
)
from adcp.types.generated_poc.creative.preview_creative_response import PreviewCreativeResponse3
from adcp.types.generated_poc.media_buy.build_creative_response import (
BuildCreativeResponse1,
BuildCreativeResponse3,
BuildCreativeResponse4,
)
from adcp.types.generated_poc.media_buy.build_creative_response import (
Creative as BuildCreative,
)
from adcp.types.generated_poc.media_buy.build_creative_response import (
Variant as BuildCreativeVariant,
)
from adcp.types.generated_poc.media_buy.create_media_buy_request import CreateMediaBuyRequest
from adcp.types.generated_poc.media_buy.package_control import PackageControl
from adcp.types.generated_poc.media_buy.package_request import PackageRequest
Expand All @@ -84,13 +109,106 @@
AcceptancePolicyDiscovery,
PrimaryCountry,
)
from adcp.types.generated_poc.trusted_match.context_match_response import (
ContextMatchResponseRouterPublisher,
)
from adcp.types.generated_poc.trusted_match.offer import Offer
from adcp.types.generated_poc.trusted_match.provider_context_match_response import (
ContextMatchResponseProviderRouter,
)

_OpenCanonicalFormatKind = Annotated[
CanonicalFormatKind | str,
Field(union_mode="left_to_right"),
]


class _ManifestReadbackModel(AdCPBaseModel):
"""Independent from strict inputs: tolerant instances must not validate as them."""

model_config = ConfigDict(extra="allow")

@model_validator(mode="before")
@classmethod
def _normalize_readback(cls, data: Any) -> Any:
if isinstance(data, AdCPBaseModel) and not isinstance(data, cls):
return data.model_dump(mode="python")
return data


class _VersionedManifestReadbackModel(AdcpVersionEnvelope, _ManifestReadbackModel):
"""Keep the shared version envelope on build response nodes."""


def _manifest_readback_clone(
name: str,
source: type[AdCPBaseModel],
overrides: dict[str, Any],
*,
validators: dict[str, Any] | None = None,
) -> type[AdCPBaseModel]:
# Copy all field constraints without inheriting the strict source model.
# A tolerant subclass would pass its parent's default instance validation.
fields: dict[str, Any] = {
key: (overrides.get(key, field.annotation), deepcopy(field))
for key, field in source.model_fields.items()
}
return create_model(
name,
__base__=(
_VersionedManifestReadbackModel
if issubclass(source, AdcpVersionEnvelope)
else _ManifestReadbackModel
),
__module__=__name__,
__validators__=validators,
**fields,
)


def _normalize_readback_manifest(data: Any) -> Any:
# Pydantic binds the generated validator proxy to a callable at runtime.
normalize = cast(Callable[[Any], Any], CreativeManifest._coerce_standalone_assets)
return normalize(data)


_ReadbackCreativeManifest = _manifest_readback_clone(
"_ReadbackCreativeManifest",
CreativeManifest,
{"format_kind": _OpenCanonicalFormatKind | None},
validators={
# Preserve the generated manifest's standalone-asset normalization,
# without widening that input model or inheriting from it.
"_coerce_standalone_assets": model_validator(mode="before")(_normalize_readback_manifest),
},
)
_DeliveryVariant = _manifest_readback_clone(
"_DeliveryVariant",
CreativeVariant,
{"manifest": _ReadbackCreativeManifest | None},
)
_BuildReadbackVariant = _manifest_readback_clone(
"_BuildReadbackVariant",
BuildCreativeVariant,
{"creative_manifest": _ReadbackCreativeManifest},
)
_BuildReadbackCreative = _manifest_readback_clone(
"_BuildReadbackCreative",
BuildCreative,
{
# This constraint is inside the optional union in the generated type,
# so it must stay on the non-None arm when replacing that annotation.
"variants": Annotated[GenericAlias(list, _BuildReadbackVariant), Field(min_length=1)]
| None,
},
)
_ReadbackOffer = _manifest_readback_clone(
"_ReadbackOffer",
Offer,
{"creative_manifest": _ReadbackCreativeManifest | None},
)


def _patch_model_field(model: type[BaseModel], field_name: str, new_annotation: Any) -> None:
"""Replace a Pydantic model field's annotation in-place.

Expand Down Expand Up @@ -256,22 +374,40 @@ def _apply_forward_compat() -> None:
# Refresh its cached nested validator as well as the package model itself.
CreateMediaBuyRequest.model_rebuild(force=True)

# Canonical format kinds are an open enum on consumer boundaries. Preserve
# values introduced by a newer protocol revision instead of rejecting the
# entire creative manifest. Known values still coerce to the StrEnum arm.
# All response manifests retain unknown future kinds. Patch the generated
# response classes themselves so public aliases and indirect wrappers agree;
# public/generated input manifests and direct Creative/CreativeAsset fields
# stay strict. Private readback nodes cannot bypass strict input validation.
_patch_model_field(
CreativeManifest,
DeliveryCreative,
"format_kind",
_OpenCanonicalFormatKind | None,
)
CreativeManifest.model_rebuild(force=True)
_patch_model_field(DeliveryCreative, "variants", GenericAlias(list, _DeliveryVariant))
DeliveryCreative.model_rebuild(force=True)
GetCreativeDeliveryResponse.model_rebuild(force=True)

_patch_model_field(PreviewCreativeResponse3, "manifest", _ReadbackCreativeManifest | None)
PreviewCreativeResponse3.model_rebuild(force=True)
_patch_model_field(BuildCreativeResponse1, "creative_manifest", _ReadbackCreativeManifest)
BuildCreativeResponse1.model_rebuild(force=True)
_patch_model_field(
DeliveryCreative,
"format_kind",
_OpenCanonicalFormatKind | None,
BuildCreativeResponse3, "creative_manifests", GenericAlias(list, _ReadbackCreativeManifest)
)
DeliveryCreative.model_rebuild(force=True)
BuildCreativeResponse3.model_rebuild(force=True)
_patch_model_field(
BuildCreativeResponse4, "creatives", GenericAlias(list, _BuildReadbackCreative)
)
BuildCreativeResponse4.model_rebuild(force=True)

for response in (ContextMatchResponseRouterPublisher, ContextMatchResponseProviderRouter):
_patch_model_field(response, "offers", GenericAlias(list, _ReadbackOffer))
response.model_rebuild(force=True)

# These eager wrappers captured build/preview validators before the patches.
# Refresh both levels so completed task callbacks retain typed manifests.
AdcpAsyncResponseData.model_rebuild(force=True)
McpWebhookPayload.model_rebuild(force=True)

_patch_model_field(Format, "assets", list[FormatAssetUnion] | None)
Format.model_rebuild(force=True)
Expand Down
Loading
Loading