From d38c6f996d8c24f3d66318b05cde5b610a587f7f Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 8 Oct 2026 00:16:34 -0400 Subject: [PATCH 1/6] fix(types)!: keep ProductFormatDeclaration a class, graded from its schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beta.2 fixed a real defect: at beta.1 the public `adcp.types.ProductFormatDeclaration` resolved to `canonical_creative.Format` while `aliases.py` deliberately imported a hand-rolled 161-line class, so that import was shadowed and dead, and neither type enforced the schema's root rules. Rebinding the name to the generated 16-branch union enforced them from the schema instead of from a hand-maintained restatement. The enforcement is kept; only its carrier changes. `_check_declaration_rules` already read the bundled schema, so it runs as a `model_validator` on one class rather than as a `BeforeValidator` on a union wrapper. What the class keeps that the union could not: * `ProductFormatDeclaration(...)`, `isinstance(x, ProductFormatDeclaration)` and `.model_validate(...)` all work. On the union they raise TypeError, TypeError and AttributeError. * `legacy_format_refs` and `params_as` stay reachable. The 16 generated branches declare neither; they exist only on `Format`. * One type per concept. A declaration IS a `Format`, so the eight projection entry points in `adcp.canonical_formats` accept it without the adopter restating a type. * The same spelling works on beta.1 and beta.2. `validate_union` does not exist at beta.1, so no single spelling previously spanned both. Enforcement is strictly wider than before. The rule check now evaluates the root `oneOf` as well as the `allOf`, which pulls in each branch's own `$ref`: `{"format_kind": "image", "params": {"width": 300}}` is refused by `image.json`'s size-mode mutex, and was accepted by the generated branch. The discriminator selects one branch before grading, so a bad parameter reports as `params.width` rather than a root `oneOf`. Two supporting changes were needed: * `allow_root_v1_ref`/`format_scope` were decided by `cls.__name__ == "Format"`, which no subclass can satisfy — any `Format` subclass silently lost root `v1_format_ref` handling and the format-scoped legacy-identity strip. That is now the declared, inherited `__adcp_format_declaration_scope__`. * `Format.__init__` migrates `capability_id` to `format_option_id`. The schema's allOf[4] forbids the key outright, so a class naming a root schema does not migrate it. `Format` keeps the tolerance. `revalidate_instances` stays at its default deliberately: `"always"` hands the validator every declared field with unset ones as `None`, which destroys the presence the root rules test and makes allOf[2]'s `else` branch refuse every non-custom declaration. `LegacyProductFormatDeclaration` still names the generated union for adopters who want branch discrimination. BREAKING CHANGE: `adcp.types.ProductFormatDeclaration` is a class again rather than an `Annotated` union, so `get_args()` on it no longer yields the 16 generated branches and `params` is the open bag `Format` declares rather than a typed canonical model — use `params_as(CanonicalFormatImage)` for typed parameters, or `LegacyProductFormatDeclaration` for the union. `validate_union` and `TypeAdapter` continue to work and now return the class. --- src/adcp/types/_product_format_declaration.py | 113 ++++--- src/adcp/types/aliases.py | 5 +- src/adcp/types/canonical_creative.py | 66 +++- tests/test_product_format_declaration.py | 282 ++++++++++++++++++ .../test_product_format_declaration_union.py | 203 ------------- .../type_checks/product_format_declaration.py | 65 ++++ .../product_format_declaration_union.py | 35 --- 7 files changed, 487 insertions(+), 282 deletions(-) create mode 100644 tests/test_product_format_declaration.py delete mode 100644 tests/test_product_format_declaration_union.py create mode 100644 tests/type_checks/product_format_declaration.py delete mode 100644 tests/type_checks/product_format_declaration_union.py diff --git a/src/adcp/types/_product_format_declaration.py b/src/adcp/types/_product_format_declaration.py index d678d4e7b..6c2bf7b01 100644 --- a/src/adcp/types/_product_format_declaration.py +++ b/src/adcp/types/_product_format_declaration.py @@ -1,19 +1,46 @@ -"""Schema-derived cross-field rules for the generated authoring union.""" +"""Schema-derived root rules for the canonical product format declaration. + +``core/product-format-declaration.json`` carries two kinds of root constraint +that a generated class cannot express on its own: + +* six ``allOf`` clauses — ``if``/``then``/``not`` cross-field rules over + ``required`` and ``const`` (for example ``publisher_domain`` is addressable + only alongside ``format_option_id``); +* a sixteen-branch ``oneOf`` pairing each ``format_kind`` const with the + canonical ``params`` schema for that kind. + +:func:`check_declaration_rules` evaluates both against the bundled schema, so +the rules stay derived from the signed bundle rather than restated by hand. +:class:`adcp.types.canonical_creative.ProductFormatDeclaration` runs it as a +``model_validator``, which keeps one class — constructible, ``isinstance``-able +and ``model_validate``-able — as the public authoring type. +""" from __future__ import annotations -from typing import Annotated, Any +from typing import Any -from pydantic import BaseModel, BeforeValidator +from pydantic import BaseModel from pydantic_core import PydanticCustomError -from adcp.types.domains.core.product_format_declaration import ( - ProductFormatDeclaration as _GeneratedProductFormatDeclaration, -) from adcp.validation.schema_loader import get_named_validator +PRODUCT_FORMAT_DECLARATION_SCHEMA = "core/product-format-declaration.json" + + +def check_declaration_rules(data: Any, *, schema_name: str) -> Any: + """Return the document in ``data``, refusing it if a root rule fails. + + Raises :class:`pydantic_core.PydanticCustomError` keyed on the failing + JSON Schema keyword, so the refusal carries the schema's own vocabulary + (``required``, ``not``, ``oneOf``) rather than a hand-written sentence. + + A model arrives when a sibling generated branch or another ``Format`` is + adopted. It is returned as its wire document so the declaration is built + from the same keys the rules were graded against, and so an unset default + stays absent rather than becoming an explicit null. + """ -def _check_declaration_rules(data: Any) -> Any: if isinstance(data, BaseModel): # Defaults that were never supplied are absent on the wire. Explicit # nulls retain their presence: root rules test required, not truthiness. @@ -23,35 +50,51 @@ def _check_declaration_rules(data: Any) -> Any: else: return data - validator = get_named_validator("core/product-format-declaration.json") + validator = get_named_validator(schema_name) if validator is None: - raise RuntimeError("Bundled product-format-declaration schema is unavailable") + raise RuntimeError(f"Bundled {schema_name} schema is unavailable") # Each lookup supplies an independent resolver over cached schema data. # Evolving it preserves reference resolution and format checking, while - # selecting only the normative root rules codegen cannot express. - rules = validator.evolve(schema={"allOf": validator.schema.get("allOf", [])}) - error = next(rules.iter_errors(document), None) + # selecting only the normative root rules the declared fields cannot carry: + # the cross-field allOf clauses and the format_kind/params oneOf pairing. + # ``properties``, ``required`` and ``type`` are deliberately left out — + # the model's own fields already enforce those, and including them would + # report every field error twice. + schema = validator.schema + rules = {"allOf": schema.get("allOf", [])} + branch = _select_branch(schema, document) + if branch is None: + # No branch claims this discriminator value. Reporting the whole oneOf + # names the closed set of accepted kinds, which is the useful message. + rules["oneOf"] = schema.get("oneOf", []) + else: + # Grading the one branch the discriminator selects — rather than the + # whole oneOf — keeps the failing keyword and its instance path, so a + # bad parameter reports as ``params.width`` instead of a root ``oneOf``. + rules["allOf"] = [*rules["allOf"], branch] + + error = next(validator.evolve(schema=rules).iter_errors(document), None) if error is not None: - raise PydanticCustomError(str(error.validator), error.message) - - # Preserve the public declaration's credential screening without imposing - # it on generated classes or changing their definition site. - from adcp.types.canonical_creative import _walk_for_credential_keys - - known_fields = set(validator.schema.get("properties", {})) | {"format_kind", "params"} - for bag_name, bag in ( - ("params", document.get("params")), - ("extras", {key: value for key, value in document.items() if key not in known_fields}), - ): - found = _walk_for_credential_keys(bag, path=bag_name) - if found is not None: - raise ValueError( - f"{found!r} matches a credential-shaped key suffix and cannot " - "be stored in a product format declaration" - ) - return data - - -ProductFormatDeclaration = Annotated[ - _GeneratedProductFormatDeclaration, BeforeValidator(_check_declaration_rules) -] + raise PydanticCustomError(str(error.validator), _message_with_path(error)) + return document + + +def _select_branch(schema: dict[str, Any], document: Any) -> dict[str, Any] | None: + """Return the ``oneOf`` branch the schema's discriminator selects.""" + + property_name = schema.get("discriminator", {}).get("propertyName") + if not property_name or not isinstance(document, dict): + return None + value = document.get(property_name) + for branch in schema.get("oneOf", []): + if branch.get("properties", {}).get(property_name, {}).get("const") == value: + selected: dict[str, Any] = branch + return selected + return None + + +def _message_with_path(error: Any) -> str: + """Prefix ``error``'s message with the instance path it failed at.""" + + path = ".".join(str(part) for part in error.absolute_path) + return f"{path}: {error.message}" if path else error.message diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index 5bed70f8c..276f99e93 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -403,8 +403,9 @@ def _generated_alias(name: str, fallback_name: str) -> Any: "daast_tracker", ] -# Use the generated authoring union with its schema-derived cross-field rules. -from adcp.types._product_format_declaration import ProductFormatDeclaration +# The public authoring type is a class whose root rules are read from the +# bundled schema — see canonical_creative.ProductFormatDeclaration. +from adcp.types.canonical_creative import ProductFormatDeclaration from adcp.types.domains.core.assets.pixel_tracker_asset import ( Method as PixelTrackerMethod, ) diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index e5c0d23f4..0e571a556 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -90,7 +90,10 @@ from pydantic.json_schema import GenerateJsonSchema from pydantic_core import CoreSchema -from adcp.types._product_format_declaration import ProductFormatDeclaration +from adcp.types._product_format_declaration import ( + PRODUCT_FORMAT_DECLARATION_SCHEMA, + check_declaration_rules, +) from adcp.types.base import AdCPBaseModel from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind from adcp.types.domains.core.creative_asset import CreativeAsset as _CanonicalCreativeWire @@ -484,7 +487,7 @@ def _serialize_canonical_model( return strip_legacy_creative_identity( handler(self), - _format_scope=self.__class__.__name__ == "Format", + _format_scope=type(self).__adcp_format_declaration_scope__, ) @@ -516,6 +519,13 @@ class CanonicalBoundaryModel(AdCPBaseModel): model_config = ConfigDict(extra="allow", defer_build=True) __adcp_canonical_creative_model__: ClassVar[bool] = True + # Whether this model is a format declaration, and so may carry a root + # ``v1_format_ref`` and owns the format-scoped legacy-identity strip. It is + # a declared, INHERITED capability rather than a ``cls.__name__ == + # "Format"`` comparison, which no subclass of ``Format`` could satisfy — + # a subclass would silently lose both behaviours. + __adcp_format_declaration_scope__: ClassVar[bool] = False + _serialize_canonical = model_serializer(mode="wrap")(_serialize_canonical_model) @classmethod @@ -552,8 +562,8 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: def _reject_legacy_creative_identity(cls, value: Any) -> Any: found = _legacy_creative_identity_path( value, - allow_root_v1_ref=cls.__name__ == "Format", - format_scope=cls.__name__ == "Format", + allow_root_v1_ref=cls.__adcp_format_declaration_scope__, + format_scope=cls.__adcp_format_declaration_scope__, ) if found is not None: raise ValueError( @@ -565,7 +575,7 @@ def model_dump(self, **kwargs: Any) -> dict[str, Any]: kwargs.setdefault("serialize_as_any", False) stripped: dict[str, Any] = strip_legacy_creative_identity( super().model_dump(**kwargs), - _format_scope=self.__class__.__name__ == "Format", + _format_scope=type(self).__adcp_format_declaration_scope__, ) return stripped @@ -574,7 +584,7 @@ def model_dump_json(self, **kwargs: Any) -> str: raw = super().model_dump_json(**kwargs) clean = strip_legacy_creative_identity( json.loads(raw), - _format_scope=self.__class__.__name__ == "Format", + _format_scope=type(self).__adcp_format_declaration_scope__, ) indent = kwargs.get("indent") return json.dumps( @@ -636,6 +646,14 @@ def _inherit(source: type[AdCPBaseModel], name: str) -> Any: class Format(CanonicalBoundaryModel): """Canonical format declaration exposed as ``adcp.Format``.""" + __adcp_format_declaration_scope__: ClassVar[bool] = True + + # Set by a subclass that enforces a bundled root schema. ``None`` keeps the + # permissive reader behaviour every other ``Format`` consumer relies on; + # naming a schema makes the root rules authoritative, which includes the + # ``allOf`` clause forbidding ``capability_id`` outright. + _root_schema_name: ClassVar[str | None] = None + format_option_id: str | None = Field( default=None, description="Stable option identifier within the product or publisher namespace.", @@ -686,7 +704,11 @@ def _reject_legacy_conflicts_and_credentials(cls, data: Any) -> Any: def __init__(self, **data: Any) -> None: refs = data.get("v1_format_ref") - if "capability_id" in data and "format_option_id" not in data: + if ( + "capability_id" in data + and "format_option_id" not in data + and type(self)._root_schema_name is None + ): data["format_option_id"] = data.pop("capability_id") super().__init__(**data) if self.__pydantic_extra__ is not None: @@ -719,6 +741,36 @@ def _validate_custom_shape(self) -> Format: return self +class ProductFormatDeclaration(Format): + """A product-bound canonical declaration, graded against its root schema. + + The AdCP 3.2 authoring type. It is a :class:`Format` — so every projection + entry point in :mod:`adcp.canonical_formats` accepts it, and + ``legacy_format_refs`` / ``params_as`` stay reachable — narrowed by the root + rules of ``core/product-format-declaration.json``: the six cross-field + ``allOf`` clauses and the sixteen-branch ``format_kind``/``params`` + ``oneOf``. Those are read from the bundled schema on every validation + rather than restated here, so the schema stays the only statement of them. + """ + + # ``revalidate_instances`` stays at its ``"never"`` default deliberately. + # Setting ``"always"`` hands this validator a dict of every declared field, + # unset ones included as ``None`` — which destroys the presence the root + # rules test. ``format_shape: None`` would then read as present and + # allOf[2]'s ``else`` branch would refuse every non-custom declaration. + # Presence therefore comes from the buyer's own document, or from + # ``exclude_unset`` when a model is revalidated across classes. + _root_schema_name: ClassVar[str | None] = PRODUCT_FORMAT_DECLARATION_SCHEMA + + @model_validator(mode="before") + @classmethod + def _enforce_root_schema_rules(cls, data: Any) -> Any: + schema_name = cls._root_schema_name + if schema_name is None: # pragma: no cover - set on this class + return data + return check_declaration_rules(data, schema_name=schema_name) + + class Placement(_LegacyPlacement, CanonicalBoundaryModel): """Canonical placement; ``format_options`` are canonical declarations.""" diff --git a/tests/test_product_format_declaration.py b/tests/test_product_format_declaration.py new file mode 100644 index 000000000..4ed8b4c15 --- /dev/null +++ b/tests/test_product_format_declaration.py @@ -0,0 +1,282 @@ +"""The authoring declaration is a class graded against its bundled root schema.""" + +from __future__ import annotations + +import copy +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import pytest +from pydantic import BaseModel, TypeAdapter, ValidationError + +import adcp +from adcp.canonical_formats import ( + find_declaration_by_kind, + group_declarations_by_product, + project_declaration_to_v1, +) +from adcp.types import ( + CanonicalFormatImage, + CanonicalFormatKind, + Format, + LegacyProductFormatDeclaration, + ProductFormatDeclaration, + validate_union, +) +from adcp.types.domains.core.product_format_declaration import ( + ProductFormatDeclaration as GeneratedProductFormatDeclaration, +) +from adcp.validation.schema_loader import get_named_validator + +_REF = {"agent_url": "https://creative.example.com", "id": "display_300x250"} +_SCHEMA = {"uri": "https://formats.example.com/custom.json", "digest": "sha256:" + "a" * 64} + + +def _payload(kind: str = "image") -> dict[str, Any]: + data: dict[str, Any] = {"format_kind": kind, "params": {}} + if kind == "custom": + data.update(format_shape="roadblock", format_schema=_SCHEMA, canonical_formats_only=True) + elif kind == "seller_rendered_stateful_display": + data["params"] = { + "states": [ + { + "state_id": "initial", + "anchoring": "inline", + "breakpoints": [{"breakpoint_id": "desktop", "width": 300, "height": 250}], + "close_affordance": False, + } + ], + "initial_state_id": "initial", + "user_controls": {"dismissible": False, "user_collapsible": False}, + } + elif kind == "coordinated_placements": + data["params"] = { + "components": [ + { + "component_id": identifier, + "placement_ref": {"placement_id": identifier}, + "required": True, + "format_option_ref": { + "scope": "product", + "format_option_id": f"{identifier}_image", + }, + } + for identifier in ("left", "right") + ] + } + return data + + +# --- the public name is a class ------------------------------------------------ + + +def test_public_name_is_a_constructible_class() -> None: + assert adcp.ProductFormatDeclaration is ProductFormatDeclaration + assert isinstance(ProductFormatDeclaration, type) + declaration = ProductFormatDeclaration( + format_kind="image", params={"width": 300, "height": 250} + ) + assert declaration.format_kind == "image" + + +def test_public_name_supports_isinstance_and_model_validate() -> None: + declaration = ProductFormatDeclaration.model_validate(_payload()) + assert isinstance(declaration, ProductFormatDeclaration) + assert not isinstance(Format(format_kind="image", params={}), ProductFormatDeclaration) + + +def test_declaration_is_a_format_so_the_projection_surface_accepts_it() -> None: + declaration = ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {"width": 300, "height": 250}, "v1_format_ref": [_REF]} + ) + assert isinstance(declaration, Format) + assert project_declaration_to_v1(declaration) is not None + assert find_declaration_by_kind("image", [declaration]) is declaration + assert group_declarations_by_product([declaration], {"product_1": ["image"]}) is not None + + +def test_consumer_accessors_stay_reachable_on_the_public_name() -> None: + declaration = ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {"width": 300, "height": 250}, "v1_format_ref": [_REF]} + ) + assert [ref.id for ref in declaration.legacy_format_refs] == ["display_300x250"] + assert declaration.params_as(CanonicalFormatImage).width == 300 + + +def test_the_generated_union_remains_exported_under_its_own_name() -> None: + assert LegacyProductFormatDeclaration is GeneratedProductFormatDeclaration + assert ProductFormatDeclaration is not LegacyProductFormatDeclaration + assert ProductFormatDeclaration is not Format + + +# --- the schema's root rules --------------------------------------------------- + + +@pytest.mark.parametrize("kind", [kind.value for kind in CanonicalFormatKind]) +def test_every_canonical_kind_validates_through_the_public_class(kind: str) -> None: + declaration = ProductFormatDeclaration.model_validate(_payload(kind)) + assert declaration.format_kind == kind + assert isinstance(declaration, ProductFormatDeclaration) + + +def test_a_format_kind_outside_the_closed_set_is_refused() -> None: + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration.model_validate({"format_kind": "not_a_kind", "params": {}}) + assert exc.value.errors()[0]["type"] == "oneOf" + # The open consumer type still reads a kind it does not know. + assert Format(format_kind="not_a_kind", params={}).format_kind == "not_a_kind" + + +def test_per_kind_parameters_are_graded_against_the_branch_schema() -> None: + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {"width": "not-an-int", "height": 250}} + ) + error = exc.value.errors()[0] + assert error["type"] == "type" + # The discriminator selects one branch, so the refusal names the field. + assert "params.width" in error["msg"] + + +@pytest.mark.parametrize( + "changes", + [ + {"canonical_formats_only": True, "v1_format_ref": [_REF]}, + {"canonical_formats_only": True, "v1_format_ref": None}, + {"capability_id": "creative-agent-only"}, + {"publisher_domain": "publisher.example.com"}, + {"tracker_execution_contract": None}, + {"format_shape": None}, + {"format_schema": _SCHEMA}, + {"locale_policy": {"accepted_language_ranges": ["en"]}}, + ], +) +def test_every_root_rule_rejects_with_its_schema_keyword(changes: dict[str, Any]) -> None: + payload = {**_payload(), **changes} + validator = get_named_validator("core/product-format-declaration.json") + assert validator is not None + expected = next( + validator.evolve(schema={"allOf": validator.schema["allOf"]}).iter_errors(payload) + ) + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration.model_validate(payload) + assert exc.value.errors()[0]["type"] == expected.validator + + +def test_capability_id_is_refused_rather_than_migrated() -> None: + # ``Format`` keeps its reader tolerance and renames the key. + assert Format(format_kind="image", params={}, capability_id="c1").format_option_id == "c1" + # The declaration enforces the schema's allOf, which forbids the key. + for build in ( + lambda: ProductFormatDeclaration(format_kind="image", params={}, capability_id="c1"), + lambda: ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {}, "capability_id": "c1"} + ), + ): + with pytest.raises(ValidationError) as exc: + build() + assert exc.value.errors()[0]["type"] == "not" + + +@pytest.mark.parametrize( + "changes", + [ + {"v1_format_ref": [_REF]}, + {"canonical_formats_only": True}, + {"publisher_domain": "publisher.example.com", "format_option_id": "image_1"}, + {"locale_policy": {"accepted_language_ranges": ["en"]}, "canonical_formats_only": True}, + ], +) +def test_conforming_root_rule_combinations_still_validate(changes: dict[str, Any]) -> None: + payload = {**_payload(), **changes} + assert ProductFormatDeclaration.model_validate(payload).format_kind == "image" + + +def test_custom_declarations_need_the_source_schemas_complete_contract() -> None: + assert ProductFormatDeclaration.model_validate(_payload("custom")).format_kind == "custom" + with pytest.raises(ValidationError): + ProductFormatDeclaration.model_validate({"format_kind": "custom", "params": {}}) + without_projection = _payload("custom") + without_projection.pop("canonical_formats_only") + with pytest.raises(ValidationError): + ProductFormatDeclaration.model_validate(without_projection) + + +def test_explicit_presence_is_distinguished_from_an_unset_default() -> None: + # An unset default is absent from the wire document, so a rule keyed on + # ``required`` does not fire for it; an explicitly supplied null does. + assert ProductFormatDeclaration.model_validate(_payload()).format_kind == "image" + with pytest.raises(ValidationError) as exc: + ProductFormatDeclaration.model_validate({**_payload(), "tracker_execution_contract": None}) + assert exc.value.errors()[0]["type"] == "required" + + +def test_an_unset_default_is_not_read_as_present_when_a_model_is_revalidated() -> None: + # Revalidating across classes dumps with ``exclude_unset``, so the nine + # declared-but-unset fields must not arrive as explicit nulls — otherwise + # allOf[2]'s ``else`` branch would refuse every non-custom declaration. + generated = TypeAdapter(GeneratedProductFormatDeclaration).validate_python(_payload()) + assert ProductFormatDeclaration.model_validate(generated).format_kind == "image" + + +# --- behaviour the rebinding must not lose ------------------------------------ + + +@pytest.mark.parametrize( + "changes", + [{"params": {"nested": {"api_token": "credential"}}}, {"upstream_secret": "credential"}], +) +def test_credential_screening_survives_the_public_rebinding(changes: dict[str, Any]) -> None: + with pytest.raises(ValidationError, match="credential-shaped key"): + ProductFormatDeclaration.model_validate({**_payload(), **changes}) + + +def test_validation_does_not_mutate_the_buyers_document() -> None: + payload = {**_payload(), "v1_format_ref": [_REF]} + original = copy.deepcopy(payload) + ProductFormatDeclaration.model_validate(payload) + assert payload == original + + +def test_the_declaration_can_be_used_in_adopter_models() -> None: + class ProductCatalog(BaseModel): + format_options: list[ProductFormatDeclaration] + + result = ProductCatalog.model_validate({"format_options": [_payload()]}) + assert isinstance(result.format_options[0], ProductFormatDeclaration) + with pytest.raises(ValidationError): + ProductCatalog.model_validate( + { + "format_options": [ + {**_payload(), "canonical_formats_only": True, "v1_format_ref": [_REF]} + ] + } + ) + + +def test_validate_union_and_type_adapter_both_return_the_class() -> None: + for result in ( + validate_union(ProductFormatDeclaration, _payload()), + TypeAdapter(ProductFormatDeclaration).validate_python(_payload()), + ): + assert isinstance(result, ProductFormatDeclaration) + + +def test_open_consumer_format_and_params_helper_remain_available() -> None: + future = Format(format_kind="future_kind", params={}) + assert future.model_dump()["format_kind"] == "future_kind" + image = Format(format_kind="image", params={"width": 300}) + assert image.params_as(CanonicalFormatImage).width == 300 + + +def test_concurrent_authoring_validation_uses_independent_resolvers() -> None: + def validate_batch(_: int) -> None: + for _ in range(20): + assert ProductFormatDeclaration.model_validate(_payload()).format_kind == "image" + with pytest.raises(ValidationError): + ProductFormatDeclaration.model_validate( + {**_payload(), "canonical_formats_only": True, "v1_format_ref": [_REF]} + ) + + with ThreadPoolExecutor(max_workers=4) as executor: + list(executor.map(validate_batch, range(4))) diff --git a/tests/test_product_format_declaration_union.py b/tests/test_product_format_declaration_union.py deleted file mode 100644 index 857e1cc2b..000000000 --- a/tests/test_product_format_declaration_union.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Authoring declarations use generated branches and the schema's root rules.""" - -from __future__ import annotations - -import copy -from concurrent.futures import ThreadPoolExecutor -from typing import Any, get_args - -import pytest -from pydantic import BaseModel, TypeAdapter, ValidationError - -import adcp -from adcp.types import ( - CanonicalFormatImage, - CanonicalFormatKind, - Format, - LegacyProductFormatDeclaration, - ProductFormatDeclaration, - validate_union, -) -from adcp.types.domains.core.product_format_declaration import ( - ProductFormatDeclaration as GeneratedProductFormatDeclaration, -) -from adcp.validation.schema_loader import get_named_validator - -_REF = {"agent_url": "https://creative.example.com", "id": "display_300x250"} -_SCHEMA = {"uri": "https://formats.example.com/custom.json", "digest": "sha256:" + "a" * 64} - - -def _payload(kind: str = "image") -> dict[str, Any]: - data: dict[str, Any] = {"format_kind": kind, "params": {}} - if kind == "custom": - data.update(format_shape="roadblock", format_schema=_SCHEMA, canonical_formats_only=True) - elif kind == "seller_rendered_stateful_display": - data["params"] = { - "states": [ - { - "state_id": "initial", - "anchoring": "inline", - "breakpoints": [{"breakpoint_id": "desktop", "width": 300, "height": 250}], - "close_affordance": False, - } - ], - "initial_state_id": "initial", - "user_controls": {"dismissible": False, "user_collapsible": False}, - } - elif kind == "coordinated_placements": - data["params"] = { - "components": [ - { - "component_id": identifier, - "placement_ref": {"placement_id": identifier}, - "required": True, - "format_option_ref": { - "scope": "product", - "format_option_id": f"{identifier}_image", - }, - } - for identifier in ("left", "right") - ] - } - return data - - -def test_public_name_is_the_generated_union_with_validation_metadata() -> None: - assert adcp.ProductFormatDeclaration is ProductFormatDeclaration - assert ProductFormatDeclaration is not Format - assert get_args(ProductFormatDeclaration)[0] is get_args(GeneratedProductFormatDeclaration)[0] - assert LegacyProductFormatDeclaration is GeneratedProductFormatDeclaration - - -@pytest.mark.parametrize("kind", [kind.value for kind in CanonicalFormatKind]) -def test_all_sixteen_generated_branches_are_reachable(kind: str) -> None: - result = validate_union(ProductFormatDeclaration, _payload(kind)) - assert result.format_kind == kind - assert type(result).__module__ == "adcp.types.domains.core.product_format_declaration" - assert type(result) is type( - TypeAdapter(GeneratedProductFormatDeclaration).validate_python(_payload(kind)) - ) - - -def test_params_are_typed_and_round_trip_with_the_discriminator() -> None: - result = validate_union( - ProductFormatDeclaration, {"format_kind": "image", "params": {"width": 300, "height": 250}} - ) - assert isinstance(result.params, CanonicalFormatImage) - assert result.params.width == 300 - assert result.model_dump(mode="json", exclude_unset=True) == { - "format_kind": "image", - "params": {"width": 300.0, "height": 250.0}, - } - - -@pytest.mark.parametrize( - "changes", - [ - {"canonical_formats_only": True, "v1_format_ref": [_REF]}, - {"canonical_formats_only": True, "v1_format_ref": None}, - {"capability_id": "creative-agent-only"}, - {"publisher_domain": "publisher.example.com"}, - {"tracker_execution_contract": None}, - {"format_shape": None}, - {"format_schema": _SCHEMA}, - {"locale_policy": {"accepted_language_ranges": ["en"]}}, - ], -) -def test_every_root_rule_rejects_with_its_schema_keyword(changes: dict[str, Any]) -> None: - payload = {**_payload(), **changes} - validator = get_named_validator("core/product-format-declaration.json") - assert validator is not None - expected = next( - validator.evolve(schema={"allOf": validator.schema["allOf"]}).iter_errors(payload) - ) - with pytest.raises(ValidationError) as exc: - validate_union(ProductFormatDeclaration, payload) - assert exc.value.errors()[0]["type"] == expected.validator - - -@pytest.mark.parametrize( - "changes", - [ - {"v1_format_ref": [_REF]}, - {"canonical_formats_only": True}, - {"publisher_domain": "publisher.example.com", "format_option_id": "image_1"}, - {"locale_policy": {"accepted_language_ranges": ["en"]}, "canonical_formats_only": True}, - ], -) -def test_conforming_root_rule_combinations_still_validate(changes: dict[str, Any]) -> None: - payload = {**_payload(), **changes} - assert validate_union(ProductFormatDeclaration, payload).format_kind == "image" - - -def test_custom_declarations_need_the_source_schemas_complete_contract() -> None: - assert validate_union(ProductFormatDeclaration, _payload("custom")).format_kind == "custom" - with pytest.raises(ValidationError): - validate_union(ProductFormatDeclaration, {"format_kind": "custom", "params": {}}) - without_projection = _payload("custom") - without_projection.pop("canonical_formats_only") - with pytest.raises(ValidationError): - validate_union(ProductFormatDeclaration, without_projection) - - -def test_existing_generated_instances_preserve_explicit_presence_and_omit_defaults() -> None: - original = TypeAdapter(GeneratedProductFormatDeclaration).validate_python(_payload()) - assert "v1_format_ref" not in original.model_fields_set - assert validate_union(ProductFormatDeclaration, original) is original - invalid = original.model_copy(update={"canonical_formats_only": True, "v1_format_ref": None}) - with pytest.raises(ValidationError) as exc: - validate_union(ProductFormatDeclaration, invalid) - assert exc.value.errors()[0]["type"] == "not" - - -@pytest.mark.parametrize( - "changes", - [{"params": {"nested": {"api_token": "credential"}}}, {"upstream_secret": "credential"}], -) -def test_credential_screening_survives_the_public_rebinding(changes: dict[str, Any]) -> None: - with pytest.raises(ValidationError, match="credential-shaped key"): - validate_union(ProductFormatDeclaration, {**_payload(), **changes}) - - -def test_validation_does_not_mutate_the_buyers_document() -> None: - payload = {**_payload(), "v1_format_ref": [_REF]} - original = copy.deepcopy(payload) - validate_union(ProductFormatDeclaration, payload) - assert payload == original - - -def test_authoring_union_can_be_used_in_adopter_models() -> None: - class ProductCatalog(BaseModel): - format_options: list[ProductFormatDeclaration] - - result = ProductCatalog.model_validate({"format_options": [_payload()]}) - assert result.format_options[0].format_kind == "image" - with pytest.raises(ValidationError): - ProductCatalog.model_validate( - { - "format_options": [ - {**_payload(), "canonical_formats_only": True, "v1_format_ref": [_REF]} - ] - } - ) - - -def test_open_consumer_format_and_params_helper_remain_available() -> None: - future = Format(format_kind="future_kind", params={}) - assert future.model_dump()["format_kind"] == "future_kind" - image = Format(format_kind="image", params={"width": 300}) - assert image.params_as(CanonicalFormatImage).width == 300 - - -def test_concurrent_authoring_validation_uses_independent_resolvers() -> None: - def validate_batch(_: int) -> None: - for _ in range(20): - assert validate_union(ProductFormatDeclaration, _payload()).format_kind == "image" - with pytest.raises(ValidationError): - validate_union( - ProductFormatDeclaration, - {**_payload(), "canonical_formats_only": True, "v1_format_ref": [_REF]}, - ) - - with ThreadPoolExecutor(max_workers=4) as executor: - list(executor.map(validate_batch, range(4))) diff --git a/tests/type_checks/product_format_declaration.py b/tests/type_checks/product_format_declaration.py new file mode 100644 index 000000000..195982a4a --- /dev/null +++ b/tests/type_checks/product_format_declaration.py @@ -0,0 +1,65 @@ +"""The authoring declaration is a class; consumer formats stay open.""" + +from typing import Any + +from pydantic import BaseModel, TypeAdapter +from typing_extensions import assert_type + +from adcp.canonical_formats import ( + V2ToV1Projection, + find_declaration_by_kind, + group_declarations_by_product, + project_declaration_to_v1, + project_v1_format_to_declaration, +) +from adcp.types import Format, ProductFormatDeclaration + + +class ProductCatalog(BaseModel): + format_options: list[ProductFormatDeclaration] + + +def read_kind(declaration: ProductFormatDeclaration) -> str: + kind: str = declaration.format_kind + return kind + + +def read_parameter_bag(declaration: ProductFormatDeclaration) -> dict[str, Any]: + return declaration.params + + +adapter: TypeAdapter[ProductFormatDeclaration] = TypeAdapter(ProductFormatDeclaration) +catalog = ProductCatalog.model_validate( + {"format_options": [{"format_kind": "image", "params": {"width": 300, "height": 250}}]} +) +assert_type(read_kind(catalog.format_options[0]), str) + +# The public name is a class, so an adopter can construct it, validate through +# it, and narrow with it. Each of these is a type error against a union alias. +authored = ProductFormatDeclaration(format_kind="image", params={"width": 300, "height": 250}) +validated = ProductFormatDeclaration.model_validate( + {"format_kind": "image", "params": {"width": 300, "height": 250}} +) +assert_type(authored, ProductFormatDeclaration) +assert_type(validated, ProductFormatDeclaration) +assert_type(adapter.validate_python({}), ProductFormatDeclaration) +assert_type(authored.params_as(Format), Format) + + +def narrow(value: object) -> str | None: + if isinstance(value, ProductFormatDeclaration): + return value.format_kind + return None + + +# A declaration IS a Format, so every projection entry point accepts one +# without the adopter restating the type. +assert_type(project_declaration_to_v1(authored), V2ToV1Projection) +assert_type(find_declaration_by_kind("image", [authored]), Format | None) +assert_type(group_declarations_by_product([authored], {}), dict[str, list[Format]]) + +consumer = Format(format_kind="future_kind", params={}) +assert_type(consumer.format_kind, str) +projection = project_v1_format_to_declaration({}) +assert_type(projection.declaration, Format | None) +project_declaration_to_v1(consumer) diff --git a/tests/type_checks/product_format_declaration_union.py b/tests/type_checks/product_format_declaration_union.py deleted file mode 100644 index 67ee40f3b..000000000 --- a/tests/type_checks/product_format_declaration_union.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Authoring declarations bind the generated union; consumer formats stay open.""" - -from typing import Any - -from pydantic import BaseModel, TypeAdapter -from typing_extensions import assert_type - -from adcp.canonical_formats import project_declaration_to_v1, project_v1_format_to_declaration -from adcp.types import Format, ProductFormatDeclaration - - -class ProductCatalog(BaseModel): - format_options: list[ProductFormatDeclaration] - - -def read_kind(declaration: ProductFormatDeclaration) -> str: - kind: str = declaration.format_kind - return kind - - -def read_parameter_model(declaration: ProductFormatDeclaration) -> object: - return declaration.params - - -adapter: TypeAdapter[Any] = TypeAdapter(ProductFormatDeclaration) -catalog = ProductCatalog.model_validate( - {"format_options": [{"format_kind": "image", "params": {"width": 300}}]} -) -assert_type(read_kind(catalog.format_options[0]), str) - -consumer = Format(format_kind="future_kind", params={}) -assert_type(consumer.format_kind, str) -projection = project_v1_format_to_declaration({}) -assert_type(projection.declaration, Format | None) -project_declaration_to_v1(consumer) From 9d15b17a768012c254890ebff02574683339403d Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 8 Oct 2026 00:27:10 -0400 Subject: [PATCH 2/6] docs(types): describe the declaration class and its schema-derived rules --- CHANGELOG.md | 16 ++++++++++++ MIGRATION_v8_to_v9.md | 2 +- docs/releasing.md | 20 ++++++++------- docs/types-9-migration.md | 38 +++++++++++++++++----------- src/adcp/types/canonical_creative.py | 11 +++++--- 5 files changed, 58 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9370c0b6d..6e2890ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +### ⚠ BREAKING CHANGES + +* **types:** `ProductFormatDeclaration` is a class again — a `Format` subclass + that reads the root rules of `core/product-format-declaration.json` on every + validation — rather than an `Annotated` union. Construction, + `model_validate`, `isinstance` and the `adcp.canonical_formats` projection + helpers all work on it, and `legacy_format_refs` / `params_as` are reachable; + `validate_union` and `TypeAdapter` keep working and return the class. + `get_args()` no longer yields the 16 generated branches and `params` is the + open bag `Format` declares — read typed parameters with + `params_as(CanonicalFormatImage)`, or use `LegacyProductFormatDeclaration` + for the branch union. Enforcement is wider: the root `oneOf` is evaluated as + well as the `allOf`, so each branch's own schema is graded — a declaration + such as `{"format_kind": "image", "params": {"width": 300}}` is now refused + by `image.json`'s size-mode mutex. Refs #1401. + ### Features * **auth:** `BearerTokenAuth.resolve_principal` supports sync/async non-bearer diff --git a/MIGRATION_v8_to_v9.md b/MIGRATION_v8_to_v9.md index a6b39e7ad..2ef211442 100644 --- a/MIGRATION_v8_to_v9.md +++ b/MIGRATION_v8_to_v9.md @@ -12,7 +12,7 @@ changes below before upgrading each client, server and reporting worker. | Generated required-field groups are enforced | Supply the fields required by a schema arm: for example, a frequency-cap field group, or packages/proposal for a media-buy request. Empty models that previously constructed can now raise `ValidationError`. See [generated type migration](docs/types-9-migration.md). | | Scalar schema roots are scalar subclasses | Replace scalar `.model_validate()` with `TypeAdapter` and remove `.root` access. See [scalar roots](docs/types-9-migration.md#1-scalar-roots-are-the-scalar-1286). | | Union roots are typing aliases; single-model roots are models | Validate union payloads with `validate_union` or a reused `TypeAdapter`. List roots still use `.root`. See [composing roots](docs/types-9-migration.md#2-composing-roots-are-what-they-compose-1353). | -| `ProductFormatDeclaration` names the schema's authoring union | Use `validate_union(ProductFormatDeclaration, payload)` for its typed branches. Use `Format` for open consumer/projection code and `params_as`. See [product declarations](docs/types-9-migration.md#product-declarations-use-the-authoring-union). | +| `ProductFormatDeclaration` enforces its schema's root rules | It is a `Format` subclass, so construction, `model_validate`, `isinstance` and the projection helpers all work on it, and it refuses a `format_kind` outside the schema's closed set. Read typed parameters with `params_as`. Use `Format` for open consumer parsing of unknown kinds, and `LegacyProductFormatDeclaration` for the generated branch union. See [product declarations](docs/types-9-migration.md#product-declarations-are-graded-against-their-root-schema). | | Structural pointer references share the selected type | Remove imports of obsolete internal per-position wrappers. Use public `adcp.types` names or `adcp.types.domains` modules. See [pointer references](docs/types-9-migration.md#3-pointer-refs-resolve-to-the-selected-type-1371). | | Format-kind fields preserve open strings | Replace enum identity comparisons with string equality. Opt into `CanonicalFormatKindStr` when your application needs a closed vocabulary. See [format-kind validation](docs/canonical-format-kinds-migration.md). | | Reporting generations require authenticated caller ownership | Include `consumer_id` in custom store/binding implementations and typed constructors. Migrate populated ledgers with stopped workers; an entirely empty ledger can use the explicit replacement API. See [reporting ownership migration](docs/reporting-caller-ownership-migration.md). | diff --git a/docs/releasing.md b/docs/releasing.md index 6bb2f7f78..f3a8ce8d1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -24,15 +24,17 @@ before confirming; the PR title and description alone are not parsed. After re-enabling a disabled Release Please workflow, run it once manually on `main` to process the current head; subsequent main pushes start it automatically. -Before SDK 9 GA, resolve the public declaration naming in -[#1401](https://github.com/adcontextprotocol/adcp-client-python/issues/1401). -`ProductFormatDeclaration` currently aliases the canonical `Format` helper, -while the schema's discriminated union is exposed as -`LegacyProductFormatDeclaration`. Simply rebinding the public name to that -union loses the mutual-exclusion validator for `canonical_formats_only` and -`v1_format_ref`. The fix must preserve that validation, credential-key -screening, typed parameter access, and the explicit legacy projection -boundary. Make any public-name changes during beta and document the migration. +The public declaration naming in +[#1401](https://github.com/adcontextprotocol/adcp-client-python/issues/1401) is +resolved: `ProductFormatDeclaration` is a `Format` subclass that reads the root +rules of `core/product-format-declaration.json` on every validation. That keeps +the four things a public-name change had to preserve — the +`canonical_formats_only`/`v1_format_ref` mutual exclusion, credential-key +screening, the explicit legacy projection boundary, and a constructible, +`isinstance`-able type whose `params_as` reaches typed parameters. Typed +`params` attribute access lives on `LegacyProductFormatDeclaration`, the +generated branch union. Make any further public-name changes during beta and +document the migration. At the reviewed GA exit, remove the three prerelease configuration keys, update the release-channel contract test, and select `Release-As: 9.0.0`. diff --git a/docs/types-9-migration.md b/docs/types-9-migration.md index 6ac650d0e..44511d97e 100644 --- a/docs/types-9-migration.md +++ b/docs/types-9-migration.md @@ -137,27 +137,35 @@ when schemas are regenerated. A single-model root such as `CheckGovernanceRequest` keeps `model_validate(...)` and can now be subclassed with `extra="forbid"`. -### Product declarations use the authoring union +### Product declarations are graded against their root schema -`ProductFormatDeclaration` now names the 16 generated, discriminated authoring -branches from `core/product-format-declaration.json`, with its normative -cross-field rules enforced. It is no longer an alias for the open `Format` -model. Validate an authored declaration with the cached helper: +`ProductFormatDeclaration` is a `Format` subclass that enforces the root rules +of `core/product-format-declaration.json`: the six cross-field `allOf` clauses +and the sixteen-branch `format_kind`/`params` `oneOf`, including each branch's +own schema. It is no longer an alias for the open `Format` model, which +enforces none of them. ```python -from adcp.types import ProductFormatDeclaration, validate_union +from adcp.types import ProductFormatDeclaration -declaration = validate_union(ProductFormatDeclaration, { - "format_kind": "image", "params": {"width": 300, "height": 250}, -}) +declaration = ProductFormatDeclaration( + format_kind="image", params={"width": 300, "height": 250} +) ``` -The selected branch exposes typed parameters and requires a known discriminator. -Use `Format(...)` for open consumer parsing, projection helpers and the existing -`params_as` convenience method. `Format` continues to preserve unknown kind -strings and parameter fields. `LegacyProductFormatDeclaration` remains available -as a compatibility spelling for the raw generated union; new authoring code -should use the current name with its cross-field validation. +Because it is a class, `ProductFormatDeclaration(...)`, +`ProductFormatDeclaration.model_validate(...)` and +`isinstance(x, ProductFormatDeclaration)` all work, and `validate_union` and +`TypeAdapter` return it. Because it is a `Format`, every projection helper in +`adcp.canonical_formats` accepts one, and `legacy_format_refs` and `params_as` +are reachable on it. + +`params` is the open bag `Format` declares; read it as a typed model with +`declaration.params_as(CanonicalFormatImage)`. Use `Format(...)` for open +consumer parsing of kinds this SDK's pin does not know — +`ProductFormatDeclaration` refuses a `format_kind` outside the schema's closed +set, which is the producer-side rule. `LegacyProductFormatDeclaration` names the +raw generated branch union for code that wants per-branch typed parameters. ## 3. Pointer refs resolve to the selected type (#1371) diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 0e571a556..b9337bd9a 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -38,10 +38,13 @@ it the vocabulary and :func:`is_canonical_format_kind` to meet it, and leaves the decision where the knowledge is. -The explicit ``ProductFormatDeclaration`` authoring union selects one of the -sixteen generated branches and enforces the schema's root cross-field rules. -Use ``Format`` to parse consumer declarations with future kinds, and the -opt-in ``CanonicalFormatKindStr`` annotation to restrict an adopter boundary. +The ``ProductFormatDeclaration`` authoring class is a ``Format`` narrowed by +the root rules of ``core/product-format-declaration.json`` — the six +cross-field ``allOf`` clauses and the sixteen-branch ``format_kind``/``params`` +``oneOf``, both read from the bundled schema. Use ``Format`` to parse consumer +declarations with future kinds, ``LegacyProductFormatDeclaration`` for the +generated branch union, and the opt-in ``CanonicalFormatKindStr`` annotation to +restrict an adopter boundary. So the vocabulary is not discarded, it is **relocated**: :class:`CanonicalFormatKind` stays a first-class export, used for comparison From 5011f2895ec25fe36492834a70d612e4da96a778 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 8 Oct 2026 00:37:27 -0400 Subject: [PATCH 3/6] test(types): regenerate the public API snapshot for the declaration class --- tests/fixtures/public_api_snapshot.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 9b4ffb2d6..3970ce9ae 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -453,7 +453,7 @@ "Product": "adcp.types.canonical_creative", "ProductAllowedAction": "core.product_allowed_action", "ProductFilters": "adcp.types.canonical_creative", - "ProductFormatDeclaration": "union[instance:pydantic.fields:FieldInfo,instance:pydantic.functional_validators:BeforeValidator,union[core.product_format_declaration:ProductFormatDeclaration1,core.product_format_declaration:ProductFormatDeclaration10,core.product_format_declaration:ProductFormatDeclaration11,core.product_format_declaration:ProductFormatDeclaration12,core.product_format_declaration:ProductFormatDeclaration13,core.product_format_declaration:ProductFormatDeclaration14,core.product_format_declaration:ProductFormatDeclaration15,core.product_format_declaration:ProductFormatDeclaration16,core.product_format_declaration:ProductFormatDeclaration2,core.product_format_declaration:ProductFormatDeclaration3,core.product_format_declaration:ProductFormatDeclaration4,core.product_format_declaration:ProductFormatDeclaration5,core.product_format_declaration:ProductFormatDeclaration6,core.product_format_declaration:ProductFormatDeclaration7,core.product_format_declaration:ProductFormatDeclaration8,core.product_format_declaration:ProductFormatDeclaration9]]", + "ProductFormatDeclaration": "adcp.types.canonical_creative", "ProductSignalTargetingOption": "core.product_signal_targeting_option", "ProjectedMediaBuyAction": "adcp.media_buy_actions", "Property": "core.property", @@ -1327,7 +1327,7 @@ "ProductDiscoveryProductId": "media_buy.product_discovery_criteria:ProductId", "ProductFilterCountry": "core.product_filters:Country", "ProductFilters": "adcp.types.canonical_creative", - "ProductFormatDeclaration": "union[instance:pydantic.fields:FieldInfo,instance:pydantic.functional_validators:BeforeValidator,union[core.product_format_declaration:ProductFormatDeclaration1,core.product_format_declaration:ProductFormatDeclaration10,core.product_format_declaration:ProductFormatDeclaration11,core.product_format_declaration:ProductFormatDeclaration12,core.product_format_declaration:ProductFormatDeclaration13,core.product_format_declaration:ProductFormatDeclaration14,core.product_format_declaration:ProductFormatDeclaration15,core.product_format_declaration:ProductFormatDeclaration16,core.product_format_declaration:ProductFormatDeclaration2,core.product_format_declaration:ProductFormatDeclaration3,core.product_format_declaration:ProductFormatDeclaration4,core.product_format_declaration:ProductFormatDeclaration5,core.product_format_declaration:ProductFormatDeclaration6,core.product_format_declaration:ProductFormatDeclaration7,core.product_format_declaration:ProductFormatDeclaration8,core.product_format_declaration:ProductFormatDeclaration9]]", + "ProductFormatDeclaration": "adcp.types.canonical_creative", "ProductFormatSellerPreference": "core.product_format_declaration:SellerPreference", "ProductSignalTargetingOption": "core.product_signal_targeting_option", "Property": "core.property", From 7bc3e672ea564cd54102fbb8a4f953e8ef893cf7 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 8 Oct 2026 01:12:14 -0400 Subject: [PATCH 4/6] fix(types): read the format-declaration scope defensively in the wrap serializer A field annotated with a canonical model can hold a plain dict, and pydantic then hands that dict to the wrap serializer. Reading the class-level capability directly raised AttributeError on 'dict'; the class-name comparison it replaced was safe only by accident, since 'dict' != 'Format'. --- src/adcp/types/canonical_creative.py | 6 +++++- tests/test_product_format_declaration.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index b9337bd9a..049bb7f66 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -488,9 +488,13 @@ def _serialize_canonical_model( ) -> Any: """Enforce the boundary for nested and TypeAdapter serialization too.""" + # ``self`` is whatever pydantic is serializing at this position, which is not + # always a model: a field annotated with a canonical model can hold a plain + # dict (a mismatched value pydantic serializes with a warning). Read the + # capability off the type defensively, because ``dict`` does not carry it. return strip_legacy_creative_identity( handler(self), - _format_scope=type(self).__adcp_format_declaration_scope__, + _format_scope=getattr(type(self), "__adcp_format_declaration_scope__", False), ) diff --git a/tests/test_product_format_declaration.py b/tests/test_product_format_declaration.py index 4ed8b4c15..1f6117eee 100644 --- a/tests/test_product_format_declaration.py +++ b/tests/test_product_format_declaration.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import warnings from concurrent.futures import ThreadPoolExecutor from typing import Any @@ -20,6 +21,7 @@ CanonicalFormatKind, Format, LegacyProductFormatDeclaration, + Package, ProductFormatDeclaration, validate_union, ) @@ -262,6 +264,19 @@ def test_validate_union_and_type_adapter_both_return_the_class() -> None: assert isinstance(result, ProductFormatDeclaration) +def test_the_wrap_serializer_tolerates_a_dict_at_a_canonical_position() -> None: + # A field annotated with a canonical model can hold a plain dict, and the + # wrap serializer then receives that dict rather than a model. Reading the + # format-declaration capability off it must not raise. + class Response(BaseModel): + affected_packages: list[Package] | None = None + + response = Response.model_construct(affected_packages=[{"package_id": "pkg_1"}]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert response.model_dump() == {"affected_packages": [{"package_id": "pkg_1"}]} + + def test_open_consumer_format_and_params_helper_remain_available() -> None: future = Format(format_kind="future_kind", params={}) assert future.model_dump()["format_kind"] == "future_kind" From b875b23fda8fdc665a483473ca841a1b8a9ad486 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Thu, 8 Oct 2026 08:36:25 -0400 Subject: [PATCH 5/6] docs(types): show how a validated declaration is published as a Format `Product.format_options` is `list[Format]`, and a `ProductFormatDeclaration` is a `Format`, so a seller validates each stored option strictly and publishes the validated object itself with no conversion step. Record that, the wire shape it produces, and the one field it does not carry. Measured on this branch: the published element keeps its `ProductFormatDeclaration` type; `model_dump()`, `exclude_unset=True` and `exclude_none=True` all return only the fields the stored option set, with no branch defaults added; and `model_validate` on a published element re-grades it. `v1_format_ref` is legacy identity that every canonical boundary model strips from its output, so it is reachable as `legacy_format_refs` and projected with `project_declaration_to_v1`, never serialized. Refs #1449. --- MIGRATION_v8_to_v9.md | 2 +- docs/types-9-migration.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/MIGRATION_v8_to_v9.md b/MIGRATION_v8_to_v9.md index 2ef211442..f1313713a 100644 --- a/MIGRATION_v8_to_v9.md +++ b/MIGRATION_v8_to_v9.md @@ -12,7 +12,7 @@ changes below before upgrading each client, server and reporting worker. | Generated required-field groups are enforced | Supply the fields required by a schema arm: for example, a frequency-cap field group, or packages/proposal for a media-buy request. Empty models that previously constructed can now raise `ValidationError`. See [generated type migration](docs/types-9-migration.md). | | Scalar schema roots are scalar subclasses | Replace scalar `.model_validate()` with `TypeAdapter` and remove `.root` access. See [scalar roots](docs/types-9-migration.md#1-scalar-roots-are-the-scalar-1286). | | Union roots are typing aliases; single-model roots are models | Validate union payloads with `validate_union` or a reused `TypeAdapter`. List roots still use `.root`. See [composing roots](docs/types-9-migration.md#2-composing-roots-are-what-they-compose-1353). | -| `ProductFormatDeclaration` enforces its schema's root rules | It is a `Format` subclass, so construction, `model_validate`, `isinstance` and the projection helpers all work on it, and it refuses a `format_kind` outside the schema's closed set. Read typed parameters with `params_as`. Use `Format` for open consumer parsing of unknown kinds, and `LegacyProductFormatDeclaration` for the generated branch union. See [product declarations](docs/types-9-migration.md#product-declarations-are-graded-against-their-root-schema). | +| `ProductFormatDeclaration` enforces its schema's root rules | It is a `Format` subclass, so construction, `model_validate`, `isinstance` and the projection helpers all work on it, and it refuses a `format_kind` outside the schema's closed set. Read typed parameters with `params_as`. Use `Format` for open consumer parsing of unknown kinds, and `LegacyProductFormatDeclaration` for the generated branch union. A validated declaration is published on `Product.format_options` as-is. See [product declarations](docs/types-9-migration.md#product-declarations-are-graded-against-their-root-schema) and [publishing a declaration](docs/types-9-migration.md#validating-a-declaration-and-publishing-it-as-a-format). | | Structural pointer references share the selected type | Remove imports of obsolete internal per-position wrappers. Use public `adcp.types` names or `adcp.types.domains` modules. See [pointer references](docs/types-9-migration.md#3-pointer-refs-resolve-to-the-selected-type-1371). | | Format-kind fields preserve open strings | Replace enum identity comparisons with string equality. Opt into `CanonicalFormatKindStr` when your application needs a closed vocabulary. See [format-kind validation](docs/canonical-format-kinds-migration.md). | | Reporting generations require authenticated caller ownership | Include `consumer_id` in custom store/binding implementations and typed constructors. Migrate populated ledgers with stopped workers; an entirely empty ledger can use the explicit replacement API. See [reporting ownership migration](docs/reporting-caller-ownership-migration.md). | diff --git a/docs/types-9-migration.md b/docs/types-9-migration.md index 44511d97e..69ef70427 100644 --- a/docs/types-9-migration.md +++ b/docs/types-9-migration.md @@ -167,6 +167,39 @@ consumer parsing of kinds this SDK's pin does not know — set, which is the producer-side rule. `LegacyProductFormatDeclaration` names the raw generated branch union for code that wants per-branch typed parameters. +#### Validating a declaration and publishing it as a `Format` + +`Product.format_options` is `list[Format]`, so a seller converting stored +declarations into products validates each option strictly and then publishes the +validated object itself. There is no conversion step: a +`ProductFormatDeclaration` *is* a `Format`. + +```python +from adcp.types import Product, ProductFormatDeclaration + +options = [ProductFormatDeclaration.model_validate(raw) for raw in stored_options] +product = Product(product_id="p1", format_options=options, ...) +``` + +The published element keeps its `ProductFormatDeclaration` type, and the wire +document carries only the fields the stored option set — no branch defaults are +added, under `model_dump()`, `exclude_unset=True` or `exclude_none=True` alike: + +```python +product.model_dump(mode="json")["format_options"] +# [{'format_kind': 'image', 'params': {'width': 300, 'height': 250}}] +``` + +Round-tripping holds: `ProductFormatDeclaration.model_validate(...)` on a +published element re-grades it against the root schema. + +One field does not survive, by design. `v1_format_ref` is legacy identity, which +every canonical boundary model strips from its output — `Format` and +`ProductFormatDeclaration` both capture it on input and expose it as +`declaration.legacy_format_refs`, and neither serializes it. Project it +explicitly with `adcp.canonical_formats.project_declaration_to_v1(declaration)` +when a legacy peer needs `format_ids`. + ## 3. Pointer refs resolve to the selected type (#1371) A `$ref` into another schema's `properties`/`items` no longer mints a From b7bdfe4cb18e49cc56c8284dbe7f7ee52e3f9814 Mon Sep 17 00:00:00 2001 From: "Constantine.mirin" Date: Fri, 9 Oct 2026 15:10:41 -0400 Subject: [PATCH 6/6] docs(types): drop two references to the union the class replaces The format-kind section still told a reader to consult "the product authoring union described above" and the summary table still said "the authoring union still requires a known discriminator". Both describe the shape this change removes: there is no union above it any more, and the obligation they point at now belongs to a class. Measured on this branch rather than restated: ProductFormatDeclaration(format_kind="a_kind_newer_than_my_pin") REFUSED (oneOf) ProductFormatDeclaration(format_kind="video_hosted") accepted Format(format_kind="a_kind_newer_than_my_pin") retained So the surviving obligation is the root schema's closed set, the refusal carries the oneOf keyword, and open Format is still the consumer path for a kind this pin does not know. Both references now say that. Refs #1401 --- docs/types-9-migration.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/types-9-migration.md b/docs/types-9-migration.md index 69ef70427..751aeeabd 100644 --- a/docs/types-9-migration.md +++ b/docs/types-9-migration.md @@ -12,7 +12,7 @@ pass. The pull requests are linked for the full rationale and measurements. | Structural pointer refs resolve to the type they select | #1371 | 47 per-position `RootModel` wrapper names under `adcp.types._generated` | | Generated models validate `boolean`/`integer`/`number` strictly | #1375 | Payloads that relied on `"yes"`, `"1"`, `1` coercion | | Root-level `anyOf`/`oneOf` required groups are enforced | #1368 | Documents that omit every required group of 42 request/response models, on generated and canonical names alike | -| Consumer `format_kind` fields preserve open strings | [adcp#7929](https://github.com/adcontextprotocol/adcp/issues/7929) | `x.format_kind is CanonicalFormatKind.y` identity comparisons; the authoring union still requires a known discriminator | +| Consumer `format_kind` fields preserve open strings | [adcp#7929](https://github.com/adcontextprotocol/adcp/issues/7929) | `x.format_kind is CanonicalFormatKind.y` identity comparisons; `ProductFormatDeclaration` still refuses a kind outside its schema's closed set | Nothing is removed from `adcp` or `adcp.types`: every name importable before is importable after. The additions are `Issue`, `AdcpVersionEnvelope`, seven @@ -324,8 +324,10 @@ For application models that should reject unknown kinds during construction, use the opt-in `CanonicalFormatKindStr` annotation or an `AfterValidator(require_canonical_format_kind(vocabulary))`. Nullable and list annotations compose normally. See [format-kind validation](canonical-format-kinds-migration.md). -The product authoring union described above has a fixed set of discriminator -branches; use open `Format` when consuming a declaration for a future kind. +`ProductFormatDeclaration`, described above, is the producer-side exception: +it refuses a `format_kind` outside the closed set its root schema declares, +and the refusal carries the `oneOf` keyword. Use open `Format` when consuming +a declaration for a kind this pin does not know. **What this replaced.** Five pieces of scaffolding existed only to reconcile the closed enum with the open requirement, and all five are gone: the