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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion MIGRATION_v8_to_v9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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). |
Expand Down
20 changes: 11 additions & 9 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
79 changes: 61 additions & 18 deletions docs/types-9-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -137,27 +137,68 @@ 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}
)
```

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.

#### 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 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.
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)

Expand Down Expand Up @@ -283,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
Expand Down
113 changes: 78 additions & 35 deletions src/adcp/types/_product_format_declaration.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
5 changes: 3 additions & 2 deletions src/adcp/types/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Loading
Loading